diff --git a/src/routes/notes.js b/src/routes/notes.js index 8248dc1..87981e3 100644 --- a/src/routes/notes.js +++ b/src/routes/notes.js @@ -1,16 +1,18 @@ -import { createNote, deleteNote, getNote, listNotes, updateNote } from '../services/notes.js'; -import { noteSummary } from '../utils/format.js'; +import { createNote, deleteNote, getNote, listNotes, setArchived, updateNote } from '../services/notes.js'; +import { noteDetail, noteSummary } from '../utils/format.js'; + +const notFound = { status: 404, body: { error: 'not found' } }; +const detail = (note) => (note ? { status: 200, body: noteDetail(note) } : notFound); export const notesRoutes = { - 'GET /notes': (req) => ({ status: 200, body: listNotes(req.user.id).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' } }; - }, - 'POST /notes': (req) => ({ status: 201, body: createNote(req.user.id, req.body) }), - 'PATCH /notes/:id': (req) => { - const note = updateNote(req.user.id, req.params.id, req.body ?? {}); - 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' } }), + 'GET /notes': (req) => ({ + status: 200, + body: listNotes(req.user.id, { archived: req.query?.archived === 'true' }).map(noteSummary), + }), + 'GET /notes/:id': (req) => detail(getNote(req.user.id, req.params.id)), + 'POST /notes': (req) => ({ status: 201, body: noteDetail(createNote(req.user.id, req.body)) }), + 'PATCH /notes/:id': (req) => detail(updateNote(req.user.id, req.params.id, req.body ?? {})), + 'POST /notes/:id/archive': (req) => detail(setArchived(req.user.id, req.params.id, true)), + 'POST /notes/:id/unarchive': (req) => detail(setArchived(req.user.id, req.params.id, false)), + 'DELETE /notes/:id': (req) => (deleteNote(req.user.id, req.params.id) ? { status: 204 } : notFound), }; diff --git a/src/services/notes.js b/src/services/notes.js index 653af57..4460c02 100644 --- a/src/services/notes.js +++ b/src/services/notes.js @@ -4,10 +4,10 @@ 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) { +// Notes for one owner, newest first. Archived notes are excluded unless asked for. +export function listNotes(ownerId, { archived = false } = {}) { return all('notes') - .filter((n) => n.ownerId === ownerId) + .filter((n) => n.ownerId === ownerId && Boolean(n.archived) === archived) .sort((a, b) => b.createdAt - a.createdAt); } @@ -24,6 +24,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}`); @@ -42,6 +43,13 @@ export function updateNote(ownerId, id, input) { return update('notes', id, patch); } +export function setArchived(ownerId, id, archived) { + if (!getNote(ownerId, id)) return null; + invalidate(`notes:${ownerId}`); + record(ownerId, archived ? 'note.archive' : 'note.unarchive', id); + return update('notes', id, { archived }); +} + export function deleteNote(ownerId, id) { if (!getNote(ownerId, id)) return false; invalidate(`notes:${ownerId}`); diff --git a/src/services/tags.js b/src/services/tags.js index a5a9ea2..9f96b8e 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) 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..d2491e3 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -20,9 +20,15 @@ export function noteSummary(note) { preview: truncate(note.body, 40), created: formatDate(note.createdAt), tags: [...note.tags].sort(), + archived: Boolean(note.archived), }; } +// Full note as returned by the API; seeded notes may lack the archived flag. +export function noteDetail(note) { + return { ...note, archived: Boolean(note.archived) }; +} + export function userSummary(user) { return { id: user.id, name: user.name, role: user.role }; } diff --git a/tests/api.test.js b/tests/api.test.js index 32d499e..50297a3 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -37,3 +37,70 @@ 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 = (userId, id, action = 'archive') => as(userId, { method: 'POST', path: `/notes/${id}/${action}` }); +const titles = (res) => res.body.map((n) => n.title); + +test('archive hides a note from lists and unarchive restores it', () => { + const res = archive('u1', 'n12'); + assert.equal(res.status, 200); + assert.equal(res.body.archived, true); + + const list = as('u1', { method: 'GET', path: '/notes' }); + assert.equal(list.body.length, 7); + assert.ok(!titles(list).includes('Car service')); + assert.ok(list.body.every((n) => n.archived === false)); + + const archived = as('u1', { method: 'GET', path: '/notes', query: { archived: 'true' } }); + assert.deepEqual(titles(archived), ['Car service']); + assert.equal(archived.body[0].archived, true); + + const back = archive('u1', 'n12', 'unarchive'); + assert.equal(back.status, 200); + assert.equal(back.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('an archived note can still be fetched by id', () => { + archive('u1', 'n12'); + const res = as('u1', { method: 'GET', path: '/notes/n12' }); + assert.equal(res.status, 200); + assert.equal(res.body.archived, true); + assert.equal(as('u1', { method: 'GET', path: '/notes/n11' }).body.archived, false); +}); + +test('archiving is idempotent', () => { + assert.equal(archive('u1', 'n12').status, 200); + assert.equal(archive('u1', 'n12').body.archived, true); +}); + +test("cannot archive or unarchive someone else's or a missing note", () => { + assert.equal(archive('u2', 'n12').status, 404); + assert.equal(archive('u2', 'n12', 'unarchive').status, 404); + assert.equal(archive('u1', 'nope').status, 404); + assert.equal(as('u1', { method: 'GET', path: '/notes/n12' }).body.archived, false); +}); + +test('archived notes are excluded from search', () => { + archive('u1', 'n12'); + const res = as('u1', { method: 'GET', path: '/search', query: { q: 'car' } }); + assert.ok(!titles(res).includes('Car service')); + assert.ok(res.body.every((n) => n.archived === false)); +}); + +test('archived notes are excluded from tag counts', () => { + const before = as('u1', { method: 'GET', path: '/tags/counts' }).body; + archive('u1', 'n12'); // tagged "work" + const after = as('u1', { method: 'GET', path: '/tags/counts' }).body; + assert.equal(after.work, before.work - 1); + archive('u1', 'n12', 'unarchive'); + assert.equal(as('u1', { method: 'GET', path: '/tags/counts' }).body.work, before.work); +}); + +test('created and patched notes report archived: false', () => { + const created = as('u1', { method: 'POST', path: '/notes', body: { title: 'N', body: 'B' } }); + assert.equal(created.body.archived, false); + const patched = as('u1', { method: 'PATCH', path: `/notes/${created.body.id}`, body: { title: 'M' } }); + assert.equal(patched.body.archived, false); +});