feat(api): client routes for inbox card creation and comments

This commit is contained in:
Dave 2026-09-16 09:44:04 +02:00
parent 72f9de6999
commit 76438f6e90
3 changed files with 211 additions and 3 deletions

View file

@ -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

View file

@ -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<Response> {
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<string, unknown>).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<CardRecord>(id);
} catch {
return jsonError("Card not found", 404);
}
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(card.project);
assertClientOwnsProject(session, project);
if (!canWriteClientFields(session, project.client)) {
return jsonError("Card not found", 404);
}
const comment = await pb.collection("comments").create<CommentRecord>({
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);
}
}

View file

@ -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<Response> {
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<string, unknown>).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<ProjectRecord>(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<ColumnRecord>(
`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<CardRecord>(`column = "${inbox.id}"`, {
sort: "-position",
})
.catch(() => null);
const card = await pb.collection("cards").create<CardRecord>({
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);
}
}