feat(api): client routes for inbox card creation and comments
This commit is contained in:
parent
72f9de6999
commit
76438f6e90
3 changed files with 211 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
85
src/app/api/client/cards/[id]/comments/route.ts
Normal file
85
src/app/api/client/cards/[id]/comments/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
123
src/app/api/client/cards/route.ts
Normal file
123
src/app/api/client/cards/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue