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>
94 lines
4.0 KiB
TypeScript
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 });
|
|
});
|