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.sort_order ASC, 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(); const maxRow = this.db.prepare(`SELECT COALESCE(MAX(sort_order), -1) AS m FROM notebooks`).get() as { m: number }; const sortOrder = maxRow.m + 1; this.db.prepare( `INSERT INTO notebooks(id, name, color, created_at, updated_at, sort_order) VALUES(?,?,?,?,?,?)` ).run(id, input.name, input.color ?? null, now, now, sortOrder); return { id, name: input.name, color: input.color ?? null, createdAt: now, updatedAt: now, noteCount: 0 }; } reorder(id: string, direction: 'up' | 'down'): { ok: boolean } { const cur = this.db.prepare(`SELECT sort_order FROM notebooks WHERE id = ?`).get(id) as { sort_order: number } | undefined; if (!cur) return { ok: false }; const neighbor = direction === 'up' ? this.db.prepare(`SELECT id, sort_order FROM notebooks WHERE sort_order < ? ORDER BY sort_order DESC LIMIT 1`).get(cur.sort_order) : this.db.prepare(`SELECT id, sort_order FROM notebooks WHERE sort_order > ? ORDER BY sort_order ASC LIMIT 1`).get(cur.sort_order); if (!neighbor) return { ok: false }; // 이미 끝 const n = neighbor as { id: string; sort_order: number }; const now = new Date().toISOString(); const tx = this.db.transaction(() => { this.db.prepare(`UPDATE notebooks SET sort_order = ?, updated_at = ? WHERE id = ?`).run(n.sort_order, now, id); this.db.prepare(`UPDATE notebooks SET sort_order = ?, updated_at = ? WHERE id = ?`).run(cur.sort_order, now, n.id); }); tx(); return { ok: true }; } 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; } } /** name 으로 notebook 조회 (COLLATE NOCASE — case-insensitive). */ findByName(name: 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.name = ? COLLATE NOCASE` ).get(name) as Record | undefined; return r ? this.hydrate(r) : null; } /** * v0.4 Task 11 — 가장 오래된 (created_at ASC LIMIT 1) notebook = default. * m008 마이그레이션이 기존 노트를 자동으로 이 notebook 에 할당. */ getDefault(): 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 ORDER BY nb.created_at ASC LIMIT 1` ).get() as Record | undefined; return r ? this.hydrate(r) : null; } /** 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 }; } }