diff --git a/src/routes/notes.js b/src/routes/notes.js index 8248dc1..934f179 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, status = 200) => (note ? { status, 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) => found(getNote(req.user.id, req.params.id)), + 'POST /notes': (req) => found(createNote(req.user.id, req.body), 201), + '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), + '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)), }; 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..6552970 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 single-note endpoints. +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..8421b0f 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -37,3 +37,73 @@ 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']); }); + +// Seed ownership: n1, n4, n7, n10 belong to u2; the rest to u1. n2 is u1's "work" note. +const post = (user, path) => as(user, { method: 'POST', path }); +const list = (user, query) => as(user, { method: 'GET', path: '/notes', query }).body; + +test('archive and unarchive return the updated note', () => { + const a = post('u1', '/notes/n2/archive'); + assert.equal(a.status, 200); + assert.equal(a.body.id, 'n2'); + assert.equal(a.body.archived, true); + const u = post('u1', '/notes/n2/unarchive'); + assert.equal(u.status, 200); + assert.equal(u.body.archived, false); +}); + +test('archiving needs a token and ownership', () => { + assert.equal(handle({ method: 'POST', path: '/notes/n2/archive', headers: {} }).status, 401); + assert.equal(post('u1', '/notes/n1/archive').status, 404); + assert.equal(post('u1', '/notes/n1/unarchive').status, 404); + assert.equal(post('u1', '/notes/nope/archive').status, 404); + assert.equal(as('u2', { method: 'GET', path: '/notes/n1' }).body.archived, false); +}); + +test('archived notes leave the list and search, and come back', () => { + const search = () => as('u1', { method: 'GET', path: '/search', query: { q: 'book' } }).body.map((n) => n.id); + assert.deepEqual(search(), ['n2']); + post('u1', '/notes/n2/archive'); + assert.equal(list('u1').length, 7); + assert.ok(!list('u1').some((n) => n.id === 'n2')); + assert.deepEqual(search(), []); + post('u1', '/notes/n2/unarchive'); + assert.equal(list('u1').length, 8); + assert.deepEqual(search(), ['n2']); +}); + +test('?archived=true lists only my archived notes', () => { + assert.deepEqual(list('u1', { archived: 'true' }), []); + post('u1', '/notes/n2/archive'); + post('u2', '/notes/n1/archive'); + const res = list('u1', { archived: 'true' }); + assert.deepEqual(res.map((n) => n.id), ['n2']); + assert.equal(res[0].archived, true); +}); + +test('GET /notes/:id still returns an archived note', () => { + post('u1', '/notes/n2/archive'); + const res = as('u1', { method: 'GET', path: '/notes/n2' }); + assert.equal(res.status, 200); + assert.equal(res.body.archived, true); +}); + +test('tag counts exclude archived notes', () => { + const counts = () => as('u1', { method: 'GET', path: '/tags/counts' }).body; + assert.deepEqual(counts(), { home: 4, work: 4 }); + post('u1', '/notes/n2/archive'); + assert.deepEqual(counts(), { home: 4, work: 3 }); + post('u1', '/notes/n2/unarchive'); + assert.deepEqual(counts(), { home: 4, work: 4 }); +}); + +test('every returned note carries archived: boolean', () => { + assert.ok(list('u1').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: 'T', body: 'zebra' } }); + assert.equal(created.body.archived, false); + const patched = as('u1', { method: 'PATCH', path: `/notes/${created.body.id}`, body: { title: 'T2' } }); + assert.equal(patched.body.archived, false); + const found = as('u1', { method: 'GET', path: '/search', query: { q: 'zebra' } }); + assert.equal(found.body[0].archived, false); +});