Files
platform/src/servers/api/todos/router.ts
T
pastilhasandClaude Fable 5 420cd07507 /todos: a kanban board over the todo/ tree, owner-only
The filesystem is the database: GET reads the tree through the same module the
index generator now imports, PATCH splices frontmatter (status, priority, due,
order) and never touches a body byte, and every write regenerates TODO.md.
Columns open/doing/blocked/done, native drag between them, selection and theme
filter in the URL. Registry entry 'todos' (kind admin, 22 entries, docs:check
green), isSuperAdmin double-gate on the router.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 00:18:14 +00:00

94 lines
4.0 KiB
TypeScript

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();
});
/** `<theme>/<slug>`, 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=<theme>/<slug> — 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<string, string> = {};
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 });
});