/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>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
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 });
|
||||
});
|
||||
Reference in New Issue
Block a user