diff --git a/docs/superpowers/plans/2026-09-16-phase-1b-design-system-and-board.md b/docs/superpowers/plans/2026-09-16-phase-1b-design-system-and-board.md index 60f55a4..aa16dbd 100644 --- a/docs/superpowers/plans/2026-09-16-phase-1b-design-system-and-board.md +++ b/docs/superpowers/plans/2026-09-16-phase-1b-design-system-and-board.md @@ -4,9 +4,9 @@ **Goal:** Build the themed design system, the application shell, and a working Kanban board that the owner can drag cards around and a client manager can read and set priorities on. -**Architecture:** Semantic CSS custom properties defined once per theme and exposed to Tailwind 4 through `@theme inline`, so no component ever hardcodes a colour. A server component loads a project's board using the signed-in user's own PocketBase token, which means the read rules from Plan 1a are the real boundary rather than a UI convention. Drag and drop is `@dnd-kit`, with the position arithmetic extracted into a pure, unit-tested module. The owner writes card moves straight to PocketBase; a client manager's edits go through the `/api/client/*` routes built in Plan 1a. +**Architecture:** Semantic CSS custom properties defined once per theme and exposed to Tailwind 4 through `@theme inline`, so no component ever hardcodes a colour. A server component loads a project's board using the signed-in user's own PocketBase token, which means the read rules from Plan 1a are the real boundary rather than a UI convention. Drag and drop is `@dnd-kit`, with the position arithmetic extracted into a pure, unit-tested module. Every write goes through a server route, because Plan 1a's auth token lives in an httpOnly cookie the browser SDK cannot read — the owner's card moves through `/api/owner/*`, the client manager's two editable fields through the `/api/client/*` routes already built. -**Tech Stack:** Next.js 16 · React 19 · Tailwind 4 · Radix primitives · @dnd-kit · PocketBase realtime · vitest +**Tech Stack:** Next.js 16 · React 19 · Tailwind 4 · Radix primitives · @dnd-kit · vitest **Spec:** `docs/superpowers/specs/2026-09-16-client-board-phase-1-design.md` §8 @@ -57,7 +57,7 @@ src/ │ │ ├── board-column.tsx │ │ ├── card-tile.tsx │ │ ├── client-card-controls.tsx # priority + date, client only -│ │ └── use-board-realtime.ts +│ │ └── use-refresh-on-focus.ts │ └── ui/ │ ├── pill.tsx │ ├── icon-button.tsx diff --git a/src/app/api/client/cards/[id]/comments/route.ts b/src/app/api/client/cards/[id]/comments/route.ts new file mode 100644 index 0000000..705c106 --- /dev/null +++ b/src/app/api/client/cards/[id]/comments/route.ts @@ -0,0 +1,85 @@ +import { getAdminPb } from "@/lib/pb/admin"; +import { requireSession } from "@/lib/auth/session"; +import { + AccessError, + assertClientOwnsProject, + canWriteClientFields, +} from "@/lib/auth/access"; +import { jsonError, jsonOk } from "@/lib/http/json"; +import type { CardRecord, CommentRecord, ProjectRecord } from "@/lib/pb/types"; + +/** + * POST /api/client/cards/:id/comments + * + * The body is stored as raw markdown and is never trusted. Plan 1b renders it + * with react-markdown and no raw-HTML plugin; nothing may pass it to + * dangerouslySetInnerHTML. + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> } +): Promise { + try { + const session = await requireSession(); + const { id } = await context.params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonError("Invalid request body", 400); + } + + const text = + typeof body === "object" && body !== null + ? (body as Record).body + : undefined; + + if (typeof text !== "string" || !text.trim()) { + return jsonError("A comment body is required", 400); + } + + const pb = await getAdminPb(); + + let card: CardRecord; + try { + card = await pb.collection("cards").getOne(id); + } catch { + return jsonError("Card not found", 404); + } + + const project = await pb + .collection("projects") + .getOne(card.project); + + assertClientOwnsProject(session, project); + + if (!canWriteClientFields(session, project.client)) { + return jsonError("Card not found", 404); + } + + const comment = await pb.collection("comments").create({ + card: id, + author: session.userId, + body: text.trim().slice(0, 20000), + }); + + await pb.collection("activity").create({ + project: card.project, + card: id, + actor: session.userId, + action: "comment_added", + field: "", + old_value: "", + new_value: "", + }); + + return jsonOk({ comment: { id: comment.id } }, 201); + } catch (error) { + if (error instanceof AccessError) { + return jsonError(error.message, error.status); + } + console.error("[client-comment] unexpected error", error); + return jsonError("Something went wrong", 500); + } +} \ No newline at end of file diff --git a/src/app/api/client/cards/route.ts b/src/app/api/client/cards/route.ts new file mode 100644 index 0000000..6336746 --- /dev/null +++ b/src/app/api/client/cards/route.ts @@ -0,0 +1,123 @@ +import { getAdminPb } from "@/lib/pb/admin"; +import { requireSession } from "@/lib/auth/session"; +import { + AccessError, + assertClientOwnsProject, + canWriteClientFields, +} from "@/lib/auth/access"; +import { pickClientCardCreate } from "@/lib/cards/client-patch"; +import { positionBetween } from "@/lib/ordering/fractional"; +import { jsonError, jsonOk } from "@/lib/http/json"; +import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types"; + +/** + * POST /api/client/cards — a client manager proposes a new card. + * + * The destination column, priority and accepted flag are chosen here, never + * taken from the request. A card always lands in the project's Inbox at + * medium priority with accepted=false, whatever the body asks for. The owner + * accepts it by dragging it out, which is what sets accepted=true. + */ +export async function POST(request: Request): Promise { + try { + const session = await requireSession(); + + let body: unknown; + try { + body = await request.json(); + } catch { + return jsonError("Invalid request body", 400); + } + + const projectId = + typeof body === "object" && body !== null + ? (body as Record).project + : undefined; + + if (typeof projectId !== "string" || !projectId) { + return jsonError("A project id is required", 400); + } + + const pb = await getAdminPb(); + + let project: ProjectRecord; + try { + project = await pb + .collection("projects") + .getOne(projectId); + } catch { + return jsonError("Project not found", 404); + } + + assertClientOwnsProject(session, project); + + if (!canWriteClientFields(session, project.client)) { + return jsonError("Project not found", 404); + } + + const { input, dropped } = pickClientCardCreate(body); + + if (dropped.length > 0) { + console.warn( + `[client-create] user=${session.userId} project=${projectId} dropped=${dropped.join(",")}` + ); + } + + if (!input) { + return jsonError("A title is required", 400); + } + + const inbox = await pb + .collection("columns") + .getFirstListItem( + `project = "${projectId}" && is_inbox = true` + ) + .catch(() => null); + + if (!inbox) { + // The column invariants in src/lib/columns/seed.ts should make this + // impossible. If it happens, the project's columns are corrupt and + // silently inventing a destination would hide that. + console.error(`[client-create] project ${projectId} has no inbox column`); + return jsonError("This project cannot accept new cards", 409); + } + + const last = await pb + .collection("cards") + .getFirstListItem(`column = "${inbox.id}"`, { + sort: "-position", + }) + .catch(() => null); + + const card = await pb.collection("cards").create({ + project: projectId, + column: inbox.id, + title: input.title, + description: input.description, + position: positionBetween(last ? last.position : null, null), + priority: "medium", + created_by: session.userId, + created_by_role: "client", + accepted: false, + archived: false, + }); + + await pb.collection("activity").create({ + project: projectId, + card: card.id, + actor: session.userId, + action: "card_created", + field: "", + old_value: "", + new_value: card.title, + }); + + return jsonOk({ card: { id: card.id, title: card.title } }, 201); + } catch (error) { + if (error instanceof AccessError) { + return jsonError(error.message, error.status); + } + console.error("[client-create] unexpected error", error); + return jsonError("Something went wrong", 500); + } +} \ No newline at end of file