diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 3d47050..75e92aa 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1,6 +1,6 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; -import type { Note, NoteMedia, NoteTag } from '@shared/types'; +import type { Note, NoteMedia, NoteStatus, NoteTag } from '@shared/types'; import { kstTodayIso } from '../../shared/util/kstDate.js'; export interface CreateNoteInput { rawText: string; } @@ -410,25 +410,90 @@ export class NoteRepository { .run(now, id); } + /** + * v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed). + * status + status_changed_at + move_reason + updated_at 갱신 + deleted_at + * backward-compat 동기화 (status='trashed' → deleted_at=ts, 그 외 → NULL). + * + * 단일 transaction. 호출자가 `now` 주입 가능 (테스트성). + */ + setStatus( + id: string, + status: NoteStatus, + reason: string | null, + now: Date = new Date() + ): void { + const tx = this.db.transaction(() => { + const ts = now.toISOString(); + this.db + .prepare( + `UPDATE notes + SET status = ?, + move_reason = ?, + status_changed_at = ?, + updated_at = ? + WHERE id = ?` + ) + .run(status, reason, ts, ts, id); + // backward compat: deleted_at 컬럼은 m004 이후로도 status='trashed' 와 동기화. + if (status === 'trashed') { + this.db.prepare(`UPDATE notes SET deleted_at = ? WHERE id = ?`).run(ts, id); + } else { + this.db.prepare(`UPDATE notes SET deleted_at = NULL WHERE id = ?`).run(id); + } + }); + tx(); + } + + /** + * v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선), + * NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일). + */ + listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] { + const limit = Math.max(1, Math.min(200, opts.limit ?? 200)); + const rows = this.db + .prepare( + `SELECT * FROM notes + WHERE status = ? + ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC + LIMIT ?` + ) + .all(status, limit) as Record[]; + return rows.map((r) => this.hydrate(r)); + } + + /** + * 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 + + * v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입). + */ restoreNote(id: string): void { const tx = this.db.transaction(() => { - const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined; + const before = this.db + .prepare(`SELECT ai_status FROM notes WHERE id = ?`) + .get(id) as { ai_status: string } | undefined; + // setStatus('active', null) — reason clear + deleted_at NULL + updated_at 갱신. + this.setStatus(id, 'active', null); const now = new Date().toISOString(); - this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id); // v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성 if (before?.ai_status === 'failed') { - this.db.prepare( - `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` - ).run(now, id); - this.db.prepare( - `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` - ).run(id, now); + this.db + .prepare( + `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` + ) + .run(now, id); + this.db + .prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ) + .run(id, now); } else if (before?.ai_status === 'pending') { // pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent) - this.db.prepare( - `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` - ).run(id, now); + this.db + .prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ) + .run(id, now); } // done 노트는 재처리 안 함 (이미 결과 있음) }); @@ -669,6 +734,9 @@ export class NoteRepository { deletedAt: (row.deleted_at as string | null) ?? null, lastRecalledAt: (row.last_recalled_at as string | null) ?? null, recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null, + status: ((row.status as NoteStatus | undefined) ?? 'active'), + statusChangedAt: (row.status_changed_at as string | null) ?? null, + moveReason: (row.move_reason as string | null) ?? null, createdAt: row.created_at as string, updatedAt: row.updated_at as string, tags: tags as NoteTag[], diff --git a/src/shared/types.ts b/src/shared/types.ts index 4e78fd8..7447c6c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -13,6 +13,9 @@ export interface NoteMedia { export type AiStatus = 'pending' | 'done' | 'failed'; +// v0.2.9 Cut B — 노트 status 4분기 (사용자 액션). m004 마이그레이션 + setStatus. +export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed'; + export interface NoteTag { name: string; source: 'ai' | 'user'; @@ -37,6 +40,10 @@ export interface Note { deletedAt: string | null; lastRecalledAt: string | null; recallDismissedAt: string | null; + // 신규 v4 (v0.2.9 Cut B): + status: NoteStatus; + statusChangedAt: string | null; + moveReason: string | null; createdAt: string; updatedAt: string; tags: NoteTag[]; diff --git a/tests/unit/NoteCard.test.tsx b/tests/unit/NoteCard.test.tsx index cff2f66..7d64ddb 100644 --- a/tests/unit/NoteCard.test.tsx +++ b/tests/unit/NoteCard.test.tsx @@ -48,6 +48,9 @@ const baseNote: Note = { deletedAt: null, lastRecalledAt: null, recallDismissedAt: null, + status: 'active', + statusChangedAt: null, + moveReason: null, createdAt: '2026-05-09T00:00:00Z', updatedAt: '2026-05-09T00:00:00Z', tags: [], diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts index 37e6abb..9c7186d 100644 --- a/tests/unit/NoteRepository.test.ts +++ b/tests/unit/NoteRepository.test.ts @@ -852,3 +852,131 @@ describe('NoteRepository — failed retry helpers', () => { expect(repo.getTagIdByName('nothere')).toBeNull(); }); }); + +describe('NoteRepository — setStatus + listByStatus', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + it('setStatus updates status + reason + status_changed_at + updated_at', () => { + const { id } = repo.create({ rawText: 'test' }); + repo.setStatus(id, 'completed', '결재 끝', new Date('2026-05-10T00:00:00.000Z')); + const note = repo.findById(id)!; + expect(note.status).toBe('completed'); + expect(note.moveReason).toBe('결재 끝'); + expect(note.statusChangedAt).toBe('2026-05-10T00:00:00.000Z'); + expect(note.updatedAt).toBe('2026-05-10T00:00:00.000Z'); + }); + + it('setStatus accepts null reason', () => { + const { id } = repo.create({ rawText: 'test' }); + repo.setStatus(id, 'archived', null, new Date('2026-05-10T00:00:00.000Z')); + const note = repo.findById(id)!; + expect(note.status).toBe('archived'); + expect(note.moveReason).toBeNull(); + }); + + it('setStatus default now uses Date.now()', () => { + const { id } = repo.create({ rawText: 'test' }); + const before = Date.now(); + repo.setStatus(id, 'completed', null); + const after = Date.now(); + const note = repo.findById(id)!; + const ts = new Date(note.statusChangedAt!).getTime(); + expect(ts).toBeGreaterThanOrEqual(before); + expect(ts).toBeLessThanOrEqual(after); + }); + + it('listByStatus filters correctly', () => { + const idA = repo.create({ rawText: 'a' }).id; + const idB = repo.create({ rawText: 'b' }).id; + repo.setStatus(idB, 'archived', null, new Date('2026-05-10T00:00:00.000Z')); + + const active = repo.listByStatus('active', { limit: 10 }); + const archived = repo.listByStatus('archived', { limit: 10 }); + expect(active.map((n) => n.id)).toContain(idA); + expect(active.map((n) => n.id)).not.toContain(idB); + expect(archived.map((n) => n.id)).toContain(idB); + expect(archived.map((n) => n.id)).not.toContain(idA); + }); + + it('listByStatus orders by status_changed_at DESC (NULL falls back to created_at)', () => { + const a = repo.create({ rawText: 'a' }).id; + const b = repo.create({ rawText: 'b' }).id; + const c = repo.create({ rawText: 'c' }).id; + repo.setStatus(a, 'completed', null, new Date('2026-05-10T00:00:00.000Z')); + repo.setStatus(b, 'completed', null, new Date('2026-05-12T00:00:00.000Z')); + repo.setStatus(c, 'completed', null, new Date('2026-05-11T00:00:00.000Z')); + const r = repo.listByStatus('completed', { limit: 10 }); + expect(r.map((n) => n.id)).toEqual([b, c, a]); + }); + + it('listByStatus respects limit (cap 200)', () => { + for (let i = 0; i < 5; i++) { + const id = repo.create({ rawText: `n${i}` }).id; + repo.setStatus(id, 'archived', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`)); + } + expect(repo.listByStatus('archived', { limit: 3 })).toHaveLength(3); + expect(repo.listByStatus('archived', { limit: 100 })).toHaveLength(5); + }); + + it('listByStatus default limit 200', () => { + repo.create({ rawText: 'a' }); + expect(repo.listByStatus('active')).toHaveLength(1); + }); + + it('setStatus("trashed") syncs deleted_at (backward compat)', () => { + const { id } = repo.create({ rawText: 't' }); + repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z')); + const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as { + deleted_at: string; + }; + expect(row.deleted_at).toBe('2026-05-15T00:00:00.000Z'); + expect(repo.findById(id)!.deletedAt).toBe('2026-05-15T00:00:00.000Z'); + }); + + it('setStatus("active") clears deleted_at (restore from trash)', () => { + const { id } = repo.create({ rawText: 'r' }); + repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z')); + repo.setStatus(id, 'active', null, new Date('2026-05-16T00:00:00.000Z')); + const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as { + deleted_at: string | null; + }; + expect(row.deleted_at).toBeNull(); + expect(repo.findById(id)!.deletedAt).toBeNull(); + }); + + it('setStatus("completed"/"archived") also clears deleted_at', () => { + const { id } = repo.create({ rawText: 'r' }); + repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z')); + repo.setStatus(id, 'completed', null, new Date('2026-05-16T00:00:00.000Z')); + expect(repo.findById(id)!.deletedAt).toBeNull(); + }); + + it('newly created note hydrates as status=active', () => { + const { id } = repo.create({ rawText: 'fresh' }); + const note = repo.findById(id)!; + expect(note.status).toBe('active'); + expect(note.statusChangedAt).toBeNull(); + expect(note.moveReason).toBeNull(); + }); + + it('restoreNote sets status=active + clears moveReason', () => { + const { id } = repo.create({ rawText: 'r' }); + repo.setStatus(id, 'trashed', '실수', new Date('2026-05-15T00:00:00.000Z')); + expect(repo.findById(id)!.status).toBe('trashed'); + expect(repo.findById(id)!.moveReason).toBe('실수'); + + repo.restoreNote(id); + + const after = repo.findById(id)!; + expect(after.status).toBe('active'); + expect(after.moveReason).toBeNull(); + expect(after.deletedAt).toBeNull(); + }); +}); diff --git a/tests/unit/store.expired.test.ts b/tests/unit/store.expired.test.ts index f94e0ec..02b3735 100644 --- a/tests/unit/store.expired.test.ts +++ b/tests/unit/store.expired.test.ts @@ -32,6 +32,7 @@ const noteStub = (id: string): Note => ({ userIntent: null, intentPromptedAt: null, dueDate: '2026-04-20', dueDateEditedByUser: false, deletedAt: null, lastRecalledAt: null, recallDismissedAt: null, + status: 'active', statusChangedAt: null, moveReason: null, createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z', tags: [], media: [] }); diff --git a/tests/unit/store.recall.test.ts b/tests/unit/store.recall.test.ts index eb67a18..43e636a 100644 --- a/tests/unit/store.recall.test.ts +++ b/tests/unit/store.recall.test.ts @@ -37,7 +37,8 @@ const note = (id: string): Note => ({ dueDate: null, dueDateEditedByUser: false, userIntent: null, intentPromptedAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - deletedAt: null, lastRecalledAt: null, recallDismissedAt: null + deletedAt: null, lastRecalledAt: null, recallDismissedAt: null, + status: 'active', statusChangedAt: null, moveReason: null }); describe('store recall actions', () => { diff --git a/tests/unit/store.tagFilter.test.ts b/tests/unit/store.tagFilter.test.ts index 66dbbb5..19d1329 100644 --- a/tests/unit/store.tagFilter.test.ts +++ b/tests/unit/store.tagFilter.test.ts @@ -21,6 +21,9 @@ function sample(id: string, tags: string[]): Note { deletedAt: null, lastRecalledAt: null, recallDismissedAt: null, + status: 'active', + statusChangedAt: null, + moveReason: null, createdAt: '2026-04-26T00:00:00Z', updatedAt: '2026-04-26T00:00:00Z', tags: tags.map((name) => ({ name, source: 'ai' as const })), diff --git a/tests/unit/store.trash.test.ts b/tests/unit/store.trash.test.ts index fff4a9a..7bb173d 100644 --- a/tests/unit/store.trash.test.ts +++ b/tests/unit/store.trash.test.ts @@ -30,6 +30,7 @@ const noteStub = (id: string, deletedAt: string | null = null): Note => ({ userIntent: null, intentPromptedAt: null, dueDate: null, dueDateEditedByUser: false, deletedAt, lastRecalledAt: null, recallDismissedAt: null, + status: deletedAt ? 'trashed' : 'active', statusChangedAt: deletedAt, moveReason: null, createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z', tags: [], media: [] });