diff --git a/src/db/seed.js b/src/db/seed.js index e546fe2..5643a5e 100644 --- a/src/db/seed.js +++ b/src/db/seed.js @@ -14,6 +14,7 @@ export function seed() { body: `${title} body`, tags: i % 2 === 0 ? ['home'] : ['work'], createdAt: Date.UTC(2026, 0, 1 + i), + archived: false, }); }); insert('tags', { id: 't1', name: 'home' }); diff --git a/src/routes/notes.js b/src/routes/notes.js index 8248dc1..2e9757b 100644 --- a/src/routes/notes.js +++ b/src/routes/notes.js @@ -1,8 +1,11 @@ -import { createNote, deleteNote, getNote, listNotes, updateNote } from '../services/notes.js'; +import { createNote, deleteNote, getNote, listNotes, setArchived, updateNote } from '../services/notes.js'; import { noteSummary } from '../utils/format.js'; export const notesRoutes = { - 'GET /notes': (req) => ({ status: 200, body: listNotes(req.user.id).map(noteSummary) }), + 'GET /notes': (req) => ({ + status: 200, + body: listNotes(req.user.id, { archived: String(req.query?.archived) === 'true' }).map(noteSummary), + }), 'GET /notes/:id': (req) => { const note = getNote(req.user.id, req.params.id); return note ? { status: 200, body: note } : { status: 404, body: { error: 'not found' } }; @@ -12,5 +15,13 @@ export const notesRoutes = { const note = updateNote(req.user.id, req.params.id, req.body ?? {}); return note ? { status: 200, body: note } : { status: 404, body: { error: 'not found' } }; }, + 'POST /notes/:id/archive': (req) => { + const note = setArchived(req.user.id, req.params.id, true); + return note ? { status: 200, body: note } : { status: 404, body: { error: 'not found' } }; + }, + 'POST /notes/:id/unarchive': (req) => { + const note = setArchived(req.user.id, req.params.id, false); + return note ? { status: 200, body: note } : { status: 404, body: { error: 'not found' } }; + }, 'DELETE /notes/:id': (req) => (deleteNote(req.user.id, req.params.id) ? { status: 204 } : { status: 404, body: { error: 'not found' } }), }; diff --git a/src/services/notes.js b/src/services/notes.js index 653af57..6ffed96 100644 --- a/src/services/notes.js +++ b/src/services/notes.js @@ -4,16 +4,26 @@ import { requireString, optionalTags } from '../utils/validate.js'; import { invalidate } from './cache.js'; import { record } from './audit.js'; -// Notes for one owner, newest first. -export function listNotes(ownerId) { +const withArchived = (note) => (note ? { ...note, archived: note.archived === true } : null); + +// Notes for one owner, newest first. Only active notes unless { archived: true }. +export function listNotes(ownerId, { archived = false } = {}) { return all('notes') - .filter((n) => n.ownerId === ownerId) + .filter((n) => n.ownerId === ownerId && (n.archived === true) === archived) + .map(withArchived) .sort((a, b) => b.createdAt - a.createdAt); } export function getNote(ownerId, id) { const note = get('notes', id); - return note && note.ownerId === ownerId ? note : null; + return note && note.ownerId === ownerId ? withArchived(note) : null; +} + +export function setArchived(ownerId, id, archived) { + if (!getNote(ownerId, id)) return null; + invalidate(`notes:${ownerId}`); + record(ownerId, archived ? 'note.archive' : 'note.unarchive', id); + return withArchived(update('notes', id, { archived })); } export function createNote(ownerId, input) { @@ -24,6 +34,7 @@ export function createNote(ownerId, input) { body: requireString(input, 'body', { max: 10000 }), tags: optionalTags(input), createdAt: Date.now(), + archived: false, }; insert('notes', note); invalidate(`notes:${ownerId}`); @@ -39,7 +50,7 @@ export function updateNote(ownerId, id, input) { if (input.tags !== undefined) patch.tags = optionalTags(input); invalidate(`notes:${ownerId}`); record(ownerId, 'note.update', id); - return update('notes', id, patch); + return withArchived(update('notes', id, patch)); } export function deleteNote(ownerId, id) { diff --git a/src/services/tags.js b/src/services/tags.js index a5a9ea2..cb61d31 100644 --- a/src/services/tags.js +++ b/src/services/tags.js @@ -6,7 +6,7 @@ export function tagCounts(ownerId) { return cached(`notes:${ownerId}:tags`, () => { const counts = {}; for (const note of all('notes')) { - if (note.ownerId !== ownerId) continue; + if (note.ownerId !== ownerId || note.archived === true) continue; for (const tag of note.tags) counts[tag] = (counts[tag] ?? 0) + 1; } return counts; diff --git a/src/utils/format.js b/src/utils/format.js index 24a3a09..6917f3c 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -20,6 +20,7 @@ export function noteSummary(note) { preview: truncate(note.body, 40), created: formatDate(note.createdAt), tags: [...note.tags].sort(), + archived: note.archived === true, }; } diff --git a/tests/api.test.js b/tests/api.test.js index 32d499e..160a323 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -37,3 +37,50 @@ test('search ranks title matches first', () => { const res = as('u1', { method: 'GET', path: '/search', query: { q: 'list' } }); assert.deepEqual(res.body.map((n) => n.title), ['Book list']); }); + +const archive = (u, id, verb = 'archive') => as(u, { method: 'POST', path: `/notes/${id}/${verb}` }); + +test('archive returns the updated note and hides it from lists, search and tag counts', () => { + assert.equal(as('u1', { method: 'GET', path: '/tags/counts' }).body.work, 4); // warm cache + const res = archive('u1', 'n2'); + assert.equal(res.status, 200); + assert.equal(res.body.archived, true); + const list = as('u1', { method: 'GET', path: '/notes' }).body; + assert.equal(list.length, 7); + assert.ok(list.every((n) => n.archived === false && n.id !== 'n2')); + assert.deepEqual(as('u1', { method: 'GET', path: '/search', query: { q: 'book' } }).body, []); + assert.equal(as('u1', { method: 'GET', path: '/tags/counts' }).body.work, 3); +}); + +test('archived=true lists only archived notes; GET by id still works', () => { + archive('u1', 'n2'); + const res = as('u1', { method: 'GET', path: '/notes', query: { archived: 'true' } }); + assert.deepEqual(res.body.map((n) => [n.id, n.archived]), [['n2', true]]); + const one = as('u1', { method: 'GET', path: '/notes/n2' }); + assert.equal(one.status, 200); + assert.equal(one.body.archived, true); + assert.equal(as('u1', { method: 'GET', path: '/notes/n3' }).body.archived, false); +}); + +test('unarchive restores the note', () => { + archive('u1', 'n2'); + const res = archive('u1', 'n2', 'unarchive'); + assert.equal(res.status, 200); + assert.equal(res.body.archived, false); + assert.equal(as('u1', { method: 'GET', path: '/notes' }).body.length, 8); + assert.equal(as('u1', { method: 'GET', path: '/notes', query: { archived: 'true' } }).body.length, 0); +}); + +test("archiving someone else's or a missing note is 404", () => { + assert.equal(archive('u1', 'n1').status, 404); // owned by u2 + assert.equal(archive('u1', 'n1', 'unarchive').status, 404); + assert.equal(archive('u1', 'nope').status, 404); + assert.equal(as('u2', { method: 'GET', path: '/notes/n1' }).body.archived, false); +}); + +test('created and patched notes include archived', () => { + const created = as('u1', { method: 'POST', path: '/notes', body: { title: 'New', body: 'Text' } }); + assert.equal(created.body.archived, false); + const patched = as('u1', { method: 'PATCH', path: `/notes/${created.body.id}`, body: { title: 'X' } }); + assert.equal(patched.body.archived, false); +});