import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import type { Context } from 'hono'; import { createRouter, type HonoVariables } from '@@/create-router'; import * as errors from '@@/custom-errors'; import { TODO_STATUSES, bodyOf, frontmatter, patchFrontmatter, readTree, writeIndex } from './tree'; import { ProjectPathError, addProject, getProject, listProjects, removeProject, safeProjectPath } from './projects'; /** * Mounted at `/api/kanban`. * * ── Every item route takes a project ── * * That is the extraction, expressed in the URL. The platform's version had no such parameter because it * had no such question: there was one folder and it was found from `process.cwd()`. * * The project is resolved to a row FIRST, scoped by the caller's own user id, and the path used for the * filesystem work comes from that row — never from the request. So a caller cannot name a directory, * only choose among ones already vetted by `safeProjectPath` when they were added. * * ── Who may do what ── * * The manifest makes `/projects` owner-only: pointing this plugin at a folder is a statement about the * filesystem. Moving a card is not, so a member granted `kanban` can work the board of any project they * can see. Nothing in this file checks again — two places deciding the same thing is how they come to * disagree. */ export const router = createRouter(); // ── Projects ── router.get('/projects', async (ctx) => { const user = ctx.get('user'); return ctx.json({ projects: await listProjects(user.id as number) }); }); router.post('/projects', async (ctx) => { const user = ctx.get('user'); const { name, path } = (ctx.get('body') ?? {}) as { name?: string; path?: string }; if (!name?.trim()) throw errors.BAD_REQUEST('name is required'); try { const resolved = await safeProjectPath(path, user.email as string); return ctx.json(await addProject(user.id as number, name.trim(), resolved), 201); } catch (error) { // A bad path is the caller's words being unusable, not a fault. Anything else is a real failure and // is left to the platform's error handler rather than flattened into a 400. if (error instanceof ProjectPathError) throw errors.BAD_REQUEST(error.message); throw error; } }); router.delete('/projects/:id', async (ctx) => { const user = ctx.get('user'); const removed = await removeProject(user.id as number, Number(ctx.req.param('id'))); if (!removed) throw errors.NOT_FOUND('no such project'); // Said plainly, because "delete" next to a folder full of somebody's work deserves to be unambiguous. return ctx.json({ ok: true, note: 'The folder and its files were not touched.' }); }); // ── The board ── /** * Resolve `?project=` to a directory this caller owns. * * Every item route starts here, so there is one place where a request turns into a path on disk. */ async function rootFor(ctx: Context<{ Variables: HonoVariables }>): Promise { const user = ctx.get('user'); const id = Number(ctx.req.query('project')); if (!Number.isInteger(id)) throw errors.BAD_REQUEST('project is required'); const project = await getProject(user.id as number, id); if (!project) throw errors.NOT_FOUND('no such project'); return project.path; } /** `/`, both kebab-case — also the 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 = (root: string, id: string): string => { if (!ID_SHAPE.test(id)) throw errors.BAD_REQUEST(`not a todo id: ${id}`); const [theme, slug] = id.split('/'); // Joined onto the ROW's path, which was vetted when the project was added — never onto anything from // this request beyond the id, which the shape above has already constrained. const path = resolve(root, join(theme!, `${slug}.md`)); if (!existsSync(path)) throw errors.NOT_FOUND(`no todo at ${id}`); return path; }; /** GET /api/kanban?project= — the whole tree, no bodies. What the board renders. */ router.get('/', async (ctx) => ctx.json(readTree(await rootFor(ctx)))); /** GET /api/kanban/item?project=&id=/ — one item, body included. */ router.get('/item', async (ctx) => { const text = readFileSync(fileForId(await rootFor(ctx), ctx.req.query('id') ?? ''), 'utf8'); return ctx.json({ meta: frontmatter(text), body: bodyOf(text) }); }); /** * PATCH /api/kanban/item — update an item's frontmatter: `{ id, status?, priority?, due?, order? }`. * * **The body is untouchable through this API, on purpose.** The prose is the owner's; only the machine's * half of the file is writable here, and `patchFrontmatter` splices keys without touching a byte past * the closing fence. */ router.patch('/item', async (ctx) => { const root = await rootFor(ctx); const { id, status, priority, due, order } = (ctx.get('body') ?? {}) as { id: string; status?: string; priority?: string; due?: string; order?: number; }; const path = fileForId(root, 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. 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)); // The generated map, regenerated on every write so it cannot drift from the tree it describes. writeIndex(root); return ctx.json({ ok: true }); });