diff --git a/src/routes/notes.js b/src/routes/notes.js index 8248dc1..c2dcc9b 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 { archiveNote, createNote, deleteNote, getNote, listNotes, unarchiveNote, updateNote } from '../services/notes.js'; +import { noteDetail, noteSummary } from '../utils/format.js'; + +const NOT_FOUND = { status: 404, body: { error: 'not found' } }; +const detail = (note) => (note ? { status: 200, body: noteDetail(note) } : NOT_FOUND); 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 ?? {})), + 'DELETE /notes/:id': (req) => (deleteNote(req.user.id, req.params.id) ? { status: 204 } : NOT_FOUND), + 'POST /notes/:id/archive': (req) => detail(archiveNote(req.user.id, req.params.id)), + 'POST /notes/:id/unarchive': (req) => detail(unarchiveNote(req.user.id, req.params.id)), }; diff --git a/src/services/notes.js b/src/services/notes.js index 653af57..147678b 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 only listed on request. +export function listNotes(ownerId, { archived = false } = {}) { return all('notes') - .filter((n) => n.ownerId === ownerId) + .filter((n) => n.ownerId === ownerId && (n.archived === true) === archived) .sort((a, b) => b.createdAt - a.createdAt); } @@ -23,6 +23,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 +43,17 @@ export function updateNote(ownerId, id, input) { return update('notes', id, patch); } +function setArchived(ownerId, id, archived) { + if (!getNote(ownerId, id)) return null; + const note = update('notes', id, { archived }); + invalidate(`notes:${ownerId}`); + record(ownerId, archived ? 'note.archive' : 'note.unarchive', id); + return note; +} + +export const archiveNote = (ownerId, id) => setArchived(ownerId, id, true); +export const unarchiveNote = (ownerId, id) => setArchived(ownerId, id, false); + 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..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..dc6f329 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: note.archived === true, }; } +// Full note as returned by the detail routes. +export function noteDetail(note) { + return { ...note, archived: note.archived === true }; +} + 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..d9f1c98 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -2,9 +2,13 @@ import { test, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { seed } from '../src/db/seed.js'; import { handle } from '../src/server.js'; +import { invalidate } from '../src/services/cache.js'; import { sign } from '../src/middleware/auth.js'; -beforeEach(seed); +beforeEach(() => { + seed(); + invalidate('notes:'); +}); const as = (userId, req) => handle({ headers: { authorization: `Bearer ${sign(userId)}` }, ...req }); test('health is public', () => { @@ -37,3 +41,74 @@ 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 post = (user, path) => as(user, { method: 'POST', path }); +const get = (user, path, query) => as(user, { method: 'GET', path, query }); + +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 someone else's or a missing note is a 404", () => { + for (const action of ['archive', 'unarchive']) { + assert.equal(post('u1', `/notes/n1/${action}`).status, 404); + assert.equal(post('u1', `/notes/nope/${action}`).status, 404); + } + assert.equal(get('u2', '/notes/n1').body.archived, false); +}); + +test('archive routes need a token', () => { + assert.equal(handle({ method: 'POST', path: '/notes/n2/archive', headers: {} }).status, 401); +}); + +test('archived notes leave the list but show with archived=true', () => { + post('u1', '/notes/n2/archive'); + const list = get('u1', '/notes'); + assert.equal(list.body.length, 7); + assert.ok(!list.body.some((n) => n.id === 'n2')); + const archived = get('u1', '/notes', { archived: 'true' }); + assert.deepEqual(archived.body.map((n) => n.id), ['n2']); + assert.equal(archived.body[0].archived, true); + post('u1', '/notes/n2/unarchive'); + assert.equal(get('u1', '/notes').body.length, 8); + assert.equal(get('u1', '/notes', { archived: 'true' }).body.length, 0); +}); + +test('archived notes are hidden from search', () => { + const id = get('u1', '/search', { q: 'list' }).body[0].id; + post('u1', `/notes/${id}/archive`); + assert.deepEqual(get('u1', '/search', { q: 'list' }).body, []); +}); + +test('archived notes are not counted in tag counts', () => { + const before = get('u1', '/tags/counts').body; + const tag = get('u1', '/notes/n2').body.tags[0]; + post('u1', '/notes/n2/archive'); + assert.equal(get('u1', '/tags/counts').body[tag], before[tag] - 1); + post('u1', '/notes/n2/unarchive'); + assert.deepEqual(get('u1', '/tags/counts').body, before); +}); + +test('GET /notes/:id still returns an archived note', () => { + post('u1', '/notes/n2/archive'); + const res = get('u1', '/notes/n2'); + assert.equal(res.status, 200); + assert.equal(res.body.archived, true); +}); + +test('every returned note includes a boolean archived flag', () => { + const created = as('u1', { method: 'POST', path: '/notes', body: { title: 'T', body: 'B' } }); + const patched = as('u1', { method: 'PATCH', path: '/notes/n2', body: { title: 'X' } }); + const one = get('u1', '/notes/n3'); + const list = get('u1', '/notes'); + const search = get('u1', '/search', { q: 'list' }); + for (const n of [created.body, patched.body, one.body, ...list.body, ...search.body]) { + assert.equal(typeof n.archived, 'boolean'); + } +});