diff --git a/src/routes/notes.js b/src/routes/notes.js index 8248dc1..bd488bd 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 found = (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: String(req.query?.archived) === 'true' }).map(noteSummary), + }), + 'GET /notes/:id': (req) => found(getNote(req.user.id, req.params.id)), + 'POST /notes': (req) => ({ status: 201, body: noteDetail(createNote(req.user.id, req.body)) }), + 'POST /notes/:id/archive': (req) => found(setArchived(req.user.id, req.params.id, true)), + 'POST /notes/:id/unarchive': (req) => found(setArchived(req.user.id, req.params.id, false)), + 'PATCH /notes/:id': (req) => found(updateNote(req.user.id, req.params.id, req.body ?? {})), + '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..bfddbba 100644 --- a/src/services/notes.js +++ b/src/services/notes.js @@ -4,10 +4,11 @@ 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 +// `archived` is true, in which case only archived notes are returned. +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); } @@ -23,6 +24,7 @@ export function createNote(ownerId, input) { title: requireString(input, 'title', { max: 120 }), body: requireString(input, 'body', { max: 10000 }), tags: optionalTags(input), + archived: false, createdAt: Date.now(), }; insert('notes', note); @@ -42,6 +44,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..409111f 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -20,9 +20,14 @@ export function noteSummary(note) { preview: truncate(note.body, 40), created: formatDate(note.createdAt), tags: [...note.tags].sort(), + archived: Boolean(note.archived), }; } +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..9dfa88c 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) => as(u, { method: 'POST', path: `/notes/${id}/archive` }); + +test('notes include archived flag in lists and singly', () => { + const list = as('u1', { method: 'GET', path: '/notes' }); + assert.ok(list.body.every((n) => n.archived === false)); + assert.equal(as('u1', { method: 'GET', path: '/notes/n2' }).body.archived, false); + const created = as('u1', { method: 'POST', path: '/notes', body: { title: 'New', body: 'Text' } }); + assert.equal(created.body.archived, false); +}); + +test('archive hides note from list, search and tag counts but not GET by id', () => { + assert.equal(as('u1', { method: 'GET', path: '/tags/counts' }).body.work, 4); // primes cache + const res = archive('u1', 'n2'); + assert.equal(res.status, 200); + assert.equal(res.body.archived, true); + assert.equal(as('u1', { method: 'GET', path: '/notes' }).body.length, 7); + assert.equal(as('u1', { method: 'GET', path: '/tags/counts' }).body.work, 3); + assert.deepEqual(as('u1', { method: 'GET', path: '/search', query: { q: 'book' } }).body, []); + const one = as('u1', { method: 'GET', path: '/notes/n2' }); + assert.equal(one.status, 200); + assert.equal(one.body.archived, true); +}); + +test('GET /notes?archived=true lists only archived notes', () => { + assert.deepEqual(as('u1', { method: 'GET', path: '/notes', query: { archived: 'true' } }).body, []); + archive('u1', 'n2'); + const res = as('u1', { method: 'GET', path: '/notes', query: { archived: 'true' } }); + assert.deepEqual(res.body.map((n) => n.id), ['n2']); + assert.equal(res.body[0].archived, true); +}); + +test('unarchive restores a note', () => { + archive('u1', 'n2'); + const res = as('u1', { method: 'POST', path: '/notes/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: '/tags/counts' }).body.work, 4); +}); + +test('archive and unarchive are 404 for notes that are not yours or missing', () => { + assert.equal(archive('u2', 'n2').status, 404); + assert.equal(as('u2', { method: 'POST', path: '/notes/n2/unarchive' }).status, 404); + assert.equal(archive('u1', 'nope').status, 404); + assert.equal(as('u1', { method: 'GET', path: '/notes/n2' }).body.archived, false); +});