From caa4728e21a811f51ebdf73d4f98bfe957b0214a Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Fri, 15 May 2026 09:58:24 +0900 Subject: [PATCH] feat(notebook): NotebookRepository CRUD + noteCount + RESTRICT delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Notebook 인터페이스 src/shared/types.ts 에 추가 (noteCount = active 노트 수) - NotebookRepository.ts 신설: list / findById / create / rename / setColor / delete / moveNote - delete: FK RESTRICT 위반 → ok:false reason='has_notes', 미존재 → 'not_found' - noteCount 서브쿼리: status='active' 만 카운트 (completed/trashed 제외) - 테스트 10개 모두 통과, typecheck clean Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/repository/NotebookRepository.ts | 79 +++++++++++++++++ src/shared/types.ts | 10 +++ tests/unit/NotebookRepository.test.ts | 100 ++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 src/main/repository/NotebookRepository.ts create mode 100644 tests/unit/NotebookRepository.test.ts diff --git a/src/main/repository/NotebookRepository.ts b/src/main/repository/NotebookRepository.ts new file mode 100644 index 0000000..5315183 --- /dev/null +++ b/src/main/repository/NotebookRepository.ts @@ -0,0 +1,79 @@ +import type Database from 'better-sqlite3'; +import { v7 as uuidv7 } from 'uuid'; +import type { Notebook } from '@shared/types'; + +export class NotebookRepository { + constructor(private db: Database.Database) {} + + list(): Notebook[] { + const rows = this.db.prepare( + `SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at, + (SELECT COUNT(*) FROM notes n + WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count + FROM notebooks nb + ORDER BY nb.name ASC` + ).all() as Array>; + return rows.map((r) => this.hydrate(r)); + } + + findById(id: string): Notebook | null { + const r = this.db.prepare( + `SELECT nb.id, nb.name, nb.color, nb.created_at, nb.updated_at, + (SELECT COUNT(*) FROM notes n + WHERE n.notebook_id = nb.id AND n.status = 'active') AS note_count + FROM notebooks nb WHERE nb.id = ?` + ).get(id) as Record | undefined; + return r ? this.hydrate(r) : null; + } + + /** name 은 COLLATE NOCASE UNIQUE — case-insensitive 중복 거부. */ + create(input: { name: string; color?: string | null }): Notebook { + const id = uuidv7(); + const now = new Date().toISOString(); + this.db.prepare( + `INSERT INTO notebooks(id, name, color, created_at, updated_at) VALUES(?,?,?,?,?)` + ).run(id, input.name, input.color ?? null, now, now); + return { id, name: input.name, color: input.color ?? null, createdAt: now, updatedAt: now, noteCount: 0 }; + } + + rename(id: string, name: string): void { + const now = new Date().toISOString(); + this.db.prepare(`UPDATE notebooks SET name=?, updated_at=? WHERE id=?`).run(name, now, id); + } + + setColor(id: string, color: string | null): void { + const now = new Date().toISOString(); + this.db.prepare(`UPDATE notebooks SET color=?, updated_at=? WHERE id=?`).run(color, now, id); + } + + /** FK RESTRICT 가 메모 잔류 시 throw — ok:false 로 변환. */ + delete(id: string): { ok: true } | { ok: false; reason: 'has_notes' | 'not_found' } { + const exists = this.db.prepare(`SELECT 1 FROM notebooks WHERE id=?`).get(id); + if (!exists) return { ok: false, reason: 'not_found' }; + try { + this.db.prepare(`DELETE FROM notebooks WHERE id=?`).run(id); + return { ok: true }; + } catch (e) { + const msg = (e as Error).message; + if (msg.includes('FOREIGN KEY')) return { ok: false, reason: 'has_notes' }; + throw e; + } + } + + /** notes.notebook_id 갱신만 (status 등은 보존). */ + moveNote(noteId: string, notebookId: string): void { + this.db.prepare(`UPDATE notes SET notebook_id=?, updated_at=? WHERE id=?`) + .run(notebookId, new Date().toISOString(), noteId); + } + + private hydrate(r: Record): Notebook { + return { + id: r.id as string, + name: r.name as string, + color: (r.color as string | null) ?? null, + createdAt: r.created_at as string, + updatedAt: r.updated_at as string, + noteCount: r.note_count as number + }; + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 745b91e..1ad2a02 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -94,6 +94,16 @@ export interface Note { media: NoteMedia[]; } +// v0.4 — Notebook: 노트 묶음 단위. noteCount = status='active' 노트 수. +export interface Notebook { + id: string; + name: string; + color: string | null; + createdAt: string; + updatedAt: string; + noteCount: number; +} + export interface WeeklyContinuity { weekStart: string; // ISO date (KST 월요일) weekCount: number; diff --git a/tests/unit/NotebookRepository.test.ts b/tests/unit/NotebookRepository.test.ts new file mode 100644 index 0000000..72bf39f --- /dev/null +++ b/tests/unit/NotebookRepository.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../src/main/db/migrations/index.js'; +import { NotebookRepository } from '../../src/main/repository/NotebookRepository.js'; + +describe('NotebookRepository', () => { + let db: Database.Database; + let repo: NotebookRepository; + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + repo = new NotebookRepository(db); + }); + afterEach(() => { db.close(); }); + + it('list: 기본 notebook 1개 + noteCount 0', () => { + const all = repo.list(); + expect(all).toHaveLength(1); + expect(all[0]!.name).toBe('기본'); + expect(all[0]!.noteCount).toBe(0); + }); + + it('create: 새 notebook 추가', () => { + const nb = repo.create({ name: '회사', color: '#0a4b80' }); + expect(nb.name).toBe('회사'); + expect(nb.color).toBe('#0a4b80'); + expect(repo.list()).toHaveLength(2); + }); + + it('create: 같은 이름 두 번이면 throw', () => { + repo.create({ name: '회사' }); + expect(() => repo.create({ name: '회사' })).toThrow(); + }); + + it('rename: 이름 변경', () => { + const nb = repo.create({ name: '회사' }); + repo.rename(nb.id, '워크'); + const after = repo.findById(nb.id); + expect(after?.name).toBe('워크'); + }); + + it('delete: 메모 없으면 OK', () => { + const nb = repo.create({ name: '회사' }); + const r = repo.delete(nb.id); + expect(r.ok).toBe(true); + expect(repo.findById(nb.id)).toBeNull(); + }); + + it('delete: 메모 있으면 RESTRICT — ok:false', () => { + const nb = repo.create({ name: '회사' }); + const ts = '2026-05-14T00:00:00Z'; + db.prepare( + `INSERT INTO notes(id, raw_text, ai_status, created_at, updated_at, status, notebook_id) + VALUES('n1','t','pending',?,?,'active',?)` + ).run(ts, ts, nb.id); + const r = repo.delete(nb.id); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe('has_notes'); + }); + + it('noteCount: status="active" 만 카운트 (completed/trashed 제외)', () => { + const nb = repo.create({ name: '회사' }); + const ts = '2026-05-14T00:00:00Z'; + const insert = db.prepare( + `INSERT INTO notes(id, raw_text, ai_status, created_at, updated_at, status, notebook_id) + VALUES(?,?,?,?,?,?,?)` + ); + insert.run('n1','t','done',ts,ts,'active',nb.id); + insert.run('n2','t','done',ts,ts,'completed',nb.id); + insert.run('n3','t','done',ts,ts,'trashed',nb.id); + const found = repo.findById(nb.id); + expect(found?.noteCount).toBe(1); + }); + + it('moveNote: notebook_id 갱신', () => { + const nb = repo.create({ name: '회사' }); + const ts = '2026-05-14T00:00:00Z'; + const defaultId = repo.list().find((n) => n.name === '기본')!.id; + db.prepare( + `INSERT INTO notes(id, raw_text, ai_status, created_at, updated_at, status, notebook_id) + VALUES('n1','t','pending',?,?,'active',?)` + ).run(ts, ts, defaultId); + repo.moveNote('n1', nb.id); + const r = db.prepare(`SELECT notebook_id FROM notes WHERE id='n1'`).get() as { notebook_id: string }; + expect(r.notebook_id).toBe(nb.id); + }); + + it('setColor: 색 변경', () => { + const nb = repo.create({ name: '회사', color: '#000' }); + repo.setColor(nb.id, '#fff'); + expect(repo.findById(nb.id)?.color).toBe('#fff'); + }); + + it('delete: 존재하지 않는 id → ok:false reason="not_found"', () => { + const r = repo.delete('does-not-exist'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe('not_found'); + }); +});