import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { createRouter } from '../../create-router'; import * as errors from '@@/custom-errors'; import { isSuperAdmin } from '../../super-admin'; import { TODO_STATUSES, bodyOf, frontmatter, patchFrontmatter, readTree, writeIndex } from './todo-tree'; /* /api/todos — the board over `todo/`, the platform's own backlog (one file per item, todo/README.md). The FILESYSTEM is the database, deliberately: the items are already versioned, reviewed and synced between machines by git, and a second store would be a second truth. So this router is thin — read the tree, splice frontmatter — and everything it writes is a small, reviewable git diff. Two rules from the format's contract, enforced here rather than trusted to callers: - The BODY is the owner's half and is never rewritten. Only `patchFrontmatter` touches a file, and it stops at the closing fence. - `TODO.md` is generated output. Every write regenerates it via `writeIndex()`, so the index cannot drift from the tree — the board never edits it directly. ── Owner only, explicitly ── `todos` is `kind: 'admin'` in the permission registry, so the global gate already refuses a non-owner. This is the same belt-and-braces the other admin routers add: a registry mistake must not turn into members reading — or editing — the platform's own backlog. */ export const todosRouter = createRouter(); todosRouter.use(async (ctx, next) => { if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The todo board is owner-only'); return next(); }); /** `/`, both kebab-case — also the path traversal guard, since it admits no dot or slash pair. */ const ID_SHAPE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/; const fileForId = (id: string): string => { if (!ID_SHAPE.test(id)) throw errors.BAD_REQUEST(`not a todo id: ${id}`); const [theme, slug] = id.split('/'); const path = resolve(process.cwd(), join('todo', theme!, `${slug}.md`)); if (!existsSync(path)) throw errors.NOT_FOUND(`no todo at ${id}`); return path; }; /** GET /api/todos — the whole tree, no bodies. What the board renders. */ todosRouter.get('/', (ctx) => ctx.json(readTree())); /** GET /api/todos/item?id=/ — one item, body included. Query param because the id holds a slash. */ todosRouter.get('/item', (ctx) => { const text = readFileSync(fileForId(ctx.req.query('id') ?? ''), 'utf8'); return ctx.json({ meta: frontmatter(text), body: bodyOf(text) }); }); /** * PATCH /api/todos/item — update an item's frontmatter: `{ id, status?, priority?, due?, order? }`. * Regenerates TODO.md on every write. The body is untouchable through this API, on purpose. */ todosRouter.patch('/item', async (ctx) => { const { id, status, priority, due, order } = await ctx.req.json<{ id: string; status?: string; priority?: string; due?: string; order?: number; }>(); const path = fileForId(id); const updates: Record = {}; if (status !== undefined) { if (!(TODO_STATUSES as readonly string[]).includes(status)) throw errors.BAD_REQUEST(`not a status: ${status}`); updates.status = status; } // `urgent` or nothing — the format deliberately has no scale (todo/README.md). if (priority !== undefined) { if (priority !== '' && priority !== 'urgent') throw errors.BAD_REQUEST(`not a priority: ${priority}`); updates.priority = priority; } if (due !== undefined) { if (due !== '' && !/^20\d\d-\d\d-\d\d$/.test(due)) throw errors.BAD_REQUEST(`not a date: ${due}`); updates.due = due; } if (order !== undefined) { if (!Number.isInteger(order) || order < 0) throw errors.BAD_REQUEST(`not an order: ${order}`); updates.order = String(order); } if (Object.keys(updates).length === 0) throw errors.BAD_REQUEST('nothing to update'); writeFileSync(path, patchFrontmatter(readFileSync(path, 'utf8'), updates)); writeIndex(); return ctx.json({ ok: true }); });