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');
+ });
+});