Files
kanban/api/projects.ts
pastilhasandClaude Opus 5 6e54f223e6 Kanban — the todo board, extracted, over as many projects as you like
was `/todos` in the platform: a board, an API and a tree reader pointed at
`resolve(process.cwd(), 'todo')`. that is not a setting — it is the belief that
there is one project and it is the one the server happens to have been started in.

THE READER CAME ACROSS UNCHANGED except for that one line. the frontmatter parser
that never rewrites a body, the ordering, the index writer — all of it as it was,
and its 8 tests came too and still pass. everything that changed is that the
directory is now a parameter.

`writeIndex` no longer writes to `process.cwd()` either. that was the same
assumption wearing a different hat, and with two projects it would have had every
board overwriting one file. the map goes beside the folder it describes.

A PROJECT IS A NAME AND A PATH, and paths are confined to the owner's home by the
same three checks av-club uses — absolute, realpath BEFORE the containment test,
and `root` or `root + sep` rather than a bare startsWith. spelled out again rather
than cross-referenced, because the next person to add a path-taking route reads the
file they are in.

it stores NO ITEMS. one table, a list of folders. uninstalling with "delete its
data" ticked loses the list and never a line of anybody's work — which is the
property the markdown format was chosen for.

`/projects` is owner-only: pointing this at a directory is a statement about the
filesystem. moving a card is not, so a member granted `kanban` can work any board
they can see.

removing a project removes the ROW. the route says so in its response and the
button says so in its tooltip, because "delete" beside a folder full of somebody's
work should not be ambiguous.

README records the seam honestly: the platform keeps its own todo/ and its CLI, so
there are two copies of the reader and they will drift. the alternative was making
the platform's own backlog depend on a plugin being installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:27:56 +01:00

89 lines
4.0 KiB
TypeScript

import { realpath, stat } from 'node:fs/promises';
import { isAbsolute, resolve, sep } from 'node:path';
import { db } from 'officerdb/db';
import { and, eq } from 'drizzle-orm';
import { getOwnerHomeDir } from '@@/data-path';
import { kanbanProjects, type KanbanProject } from '../db/schema';
/**
* The list of folders this board can show, and the rule about which folders those may be.
*
* ── Every path is confined to the owner's home ──
*
* A project path is caller-supplied and is then read from and WRITTEN to — `patchFrontmatter` edits
* files under it, and `writeIndex` creates one beside it. So it goes through the same three checks
* av-club's `paths.ts` applies, for the same reasons, and they are worth repeating rather than
* cross-referencing because the next person to add a path-taking route will read whichever file they
* are in:
*
* ABSOLUTE — a relative path resolves against a working directory the caller cannot see. That is
* precisely the bug this extraction exists to fix, so accepting one here would be reintroducing it
* through the front door.
* REALPATH FIRST — a symlink inside home pointing at `/etc` is resolved and then rejected. Checking
* the path as written and opening the resolved one is the classic way this fails.
* `root` OR `root + sep` — never a bare `startsWith`, which accepts `/home/user-backup` for a root
* of `/home/user`.
*/
const inside = (root: string, target: string) => target === root || target.startsWith(root + sep);
export class ProjectPathError extends Error {}
/**
* Validate a directory a caller wants to add.
*
* It must EXIST and be a directory. A project pointing at nothing is a row that produces an empty board
* with no explanation, and the moment to say so is while somebody is looking at the form.
*/
export async function safeProjectPath(raw: unknown, email: string): Promise<string> {
if (typeof raw !== 'string' || !raw.trim()) throw new ProjectPathError('A path is required.');
const home = getOwnerHomeDir(email);
const trimmed = raw.trim();
const expanded = trimmed === '~' ? home : trimmed.startsWith('~/') ? resolve(home, trimmed.slice(2)) : trimmed;
if (!isAbsolute(expanded)) throw new ProjectPathError('The path must be absolute.');
const real = await realpath(resolve(expanded)).catch(() => null);
// One message for "outside" and "missing" together: distinguishing them turns this into an oracle for
// what exists elsewhere on the disk.
if (!real || !inside(home, real)) throw new ProjectPathError('That is not a folder this board can read.');
const info = await stat(real).catch(() => null);
if (!info?.isDirectory()) throw new ProjectPathError('That path is not a directory.');
return real;
}
export const listProjects = (userId: number): Promise<KanbanProject[]> =>
db.select().from(kanbanProjects).where(eq(kanbanProjects.userId, userId)).orderBy(kanbanProjects.name);
/**
* One project, scoped to its owner.
*
* The `userId` is in the WHERE clause rather than checked after the read. A query that fetches by id
* and then compares is one `if` away from returning somebody else's row, and that `if` is exactly what
* gets dropped in a refactor.
*/
export async function getProject(userId: number, id: number): Promise<KanbanProject | null> {
const [row] = await db
.select()
.from(kanbanProjects)
.where(and(eq(kanbanProjects.userId, userId), eq(kanbanProjects.id, id)))
.limit(1);
return row ?? null;
}
export async function addProject(userId: number, name: string, path: string): Promise<KanbanProject> {
const [row] = await db.insert(kanbanProjects).values({ userId, name, path }).returning();
return row!;
}
/** Removes the ROW. Never the folder — this plugin does not delete anybody's todo files, ever. */
export async function removeProject(userId: number, id: number): Promise<boolean> {
const rows = await db
.delete(kanbanProjects)
.where(and(eq(kanbanProjects.userId, userId), eq(kanbanProjects.id, id)))
.returning({ id: kanbanProjects.id });
return rows.length > 0;
}