From 0c6557b89dfa4d5190549cd3d70adbb97b7904c1 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 10:56:53 +0200 Subject: [PATCH] docs(plan): Phase 1c implementation plan for card detail and the Inbox flow Fourteen tasks covering the card detail page, checklists, comments, a readable activity timeline, card creation for both roles, and the accept flow that stamps start_date. Owner mutations are Server Actions on the caller's own token; client mutations keep going through the whitelisted /api/client routes. Records the constraint that activity.createRule is superuser-only, so audit rows need a dedicated helper even for the owner. Co-Authored-By: Claude Opus 5 --- ...26-09-16-phase-1c-card-detail-and-inbox.md | 2288 +++++++++++++++++ 1 file changed, 2288 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-16-phase-1c-card-detail-and-inbox.md diff --git a/docs/superpowers/plans/2026-09-16-phase-1c-card-detail-and-inbox.md b/docs/superpowers/plans/2026-09-16-phase-1c-card-detail-and-inbox.md new file mode 100644 index 0000000..6a9d787 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-phase-1c-card-detail-and-inbox.md @@ -0,0 +1,2288 @@ +# Phase 1c — Card Detail, Creation and the Inbox Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the board usable day to day — open a card, edit it, break it into subtasks, hold a conversation on it, create cards as either role, and accept a client's request off the Inbox. + +**Architecture:** Owner mutations are **Next.js Server Actions** using the owner's own PocketBase token, authorised by the owner-only write rules from Plan 1a. Client-manager mutations keep going through the hardened `/api/client/*` route handlers, because those need a field whitelist that PocketBase cannot express. Audit rows are written through a single superuser-backed helper, because `activity.createRule` is superuser-only. Card detail is a real route, not a modal, so a card can be linked to in an email. + +**Tech Stack:** Next.js 16 Server Actions · React 19 · PocketBase 0.30 · Tailwind 4 · vitest + +**Spec:** `docs/superpowers/specs/2026-09-16-client-board-phase-1-design.md` §4, §8 + +**Prerequisite:** Plans 1a and 1b complete. `npm test` shows 68 passing; `npm run build` is clean. + +--- + +## Two rules that decide everything in this plan + +### 1. Owner writes are Server Actions. Client writes stay HTTP routes. + +This is not stylistic. The two paths need different things: + +- **A client manager** may write exactly two card fields. PocketBase rules are record-level and cannot express that, so their writes go through `/api/client/*`, which authenticates as a **superuser** and copies across only whitelisted fields. Those routes exist, are tested over HTTP, and this plan does not touch them. +- **The owner** may write everything. PocketBase already enforces that through `@request.auth.role = "owner"`, so no whitelist is needed and no superuser is needed. A Server Action running with the owner's own token is the simplest correct thing, and it avoids hand-writing eight more route handlers. + +Do not "simplify" by moving client writes into Server Actions. The whitelist is the security boundary, and it belongs somewhere explicit and separately tested. + +### 2. Audit rows need a superuser, even for the owner + +`activity.createRule` is `null`, which in PocketBase means **superuser only**. The owner's own token cannot insert an activity row. Every Server Action in this plan that needs to log something calls the `logActivity` helper from Task 3, which uses `getAdminPb()` for that one insert. + +If you skip this, `app.collection("activity").create(...)` fails with a 403 inside an action that otherwise looks correct, and the card mutation appears to work while its history silently goes missing. + +### Authorisation in every action + +Server Actions are **public HTTP endpoints**. Being unexported from a page does not protect them. Every action in this plan therefore: + +1. calls `requireSession()` +2. refuses anything that is not `role === "owner"` +3. re-checks that the record belongs to a project the caller may touch + +Never rely on the component not rendering a button. + +--- + +## File structure + +``` +src/ +├── app/ +│ ├── actions/ +│ │ ├── activity.ts # logActivity — superuser-backed +│ │ ├── cards.ts # create, update, archive, accept +│ │ ├── subtasks.ts # create, toggle, rename, delete +│ │ └── comments.ts # owner comment +│ └── p/[clientSlug]/[projectSlug]/ +│ └── c/[cardId]/ +│ ├── page.tsx # card detail route +│ └── not-found.tsx +├── components/ +│ ├── card/ +│ │ ├── card-header.tsx # title + description, owner-editable +│ │ ├── subtask-list.tsx +│ │ ├── comment-thread.tsx +│ │ ├── activity-timeline.tsx +│ │ └── card-actions.tsx # accept / archive, owner only +│ └── board/ +│ ├── add-card.tsx # owner: per-column; client: Inbox +│ └── card-tile.tsx # MODIFY — link to detail +└── lib/ + ├── activity/ + │ ├── format.ts # activity row -> human sentence + │ └── format.test.ts + ├── time/ + │ ├── relative.ts # "3 hours ago" + │ └── relative.test.ts + └── data/ + └── card.ts # card detail loader +``` + +--- + +## Task 1: Relative time formatting + +Pure logic, test-first. Used by the comment thread and the activity timeline. + +**Files:** +- Create: `src/lib/time/relative.ts` +- Test: `src/lib/time/relative.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`src/lib/time/relative.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { relativeTime } from "@/lib/time/relative"; + +const now = new Date("2026-09-16T12:00:00Z"); + +describe("relativeTime", () => { + it("returns an empty string for a missing value", () => { + expect(relativeTime("", now)).toBe(""); + }); + + it("says just now under a minute", () => { + expect(relativeTime("2026-09-16 11:59:30.000Z", now)).toBe("just now"); + }); + + it("counts minutes", () => { + expect(relativeTime("2026-09-16 11:59:00.000Z", now)).toBe("1 minute ago"); + expect(relativeTime("2026-09-16 11:45:00.000Z", now)).toBe("15 minutes ago"); + }); + + it("counts hours", () => { + expect(relativeTime("2026-09-16 11:00:00.000Z", now)).toBe("1 hour ago"); + expect(relativeTime("2026-09-16 07:00:00.000Z", now)).toBe("5 hours ago"); + }); + + it("says yesterday", () => { + expect(relativeTime("2026-09-15 08:00:00.000Z", now)).toBe("yesterday"); + }); + + it("counts days up to a week", () => { + expect(relativeTime("2026-09-12 12:00:00.000Z", now)).toBe("4 days ago"); + }); + + it("falls back to a date beyond a week", () => { + expect(relativeTime("2026-09-01 12:00:00.000Z", now)).toBe("1 Sep"); + }); + + it("includes the year for another year", () => { + expect(relativeTime("2025-12-20 12:00:00.000Z", now)).toBe("20 Dec 2025"); + }); + + it("does not produce negative times for a near-future timestamp", () => { + // Clock skew between the PocketBase host and the browser can put a + // just-written record a few seconds in the future. "in -3 seconds" is + // worse than a small lie. + expect(relativeTime("2026-09-16 12:00:05.000Z", now)).toBe("just now"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/time/relative"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/time/relative.ts`: + +```ts +/** + * Human-readable elapsed time. + * + * PocketBase returns timestamps as "2026-09-16 12:00:00.000Z" — a space, not + * the "T" that Date.parse wants everywhere. Normalise before parsing or this + * silently returns NaN on some engines. + */ + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +function parse(value: string): Date { + return new Date(value.replace(" ", "T")); +} + +function plural(count: number, unit: string): string { + return `${count} ${unit}${count === 1 ? "" : "s"} ago`; +} + +export function relativeTime(value: string, now: Date = new Date()): string { + if (!value) return ""; + + const then = parse(value); + if (Number.isNaN(then.getTime())) return ""; + + const seconds = Math.round((now.getTime() - then.getTime()) / 1000); + + // Clamp small negatives: clock skew between the database host and here can + // put a record a few seconds in the future. + if (seconds < 60) return "just now"; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return plural(minutes, "minute"); + + const hours = Math.floor(minutes / 60); + if (hours < 24) return plural(hours, "hour"); + + const days = Math.floor(hours / 24); + if (days === 1) return "yesterday"; + if (days < 7) return plural(days, "day"); + + const day = then.getUTCDate(); + const month = MONTHS[then.getUTCMonth()]; + const year = then.getUTCFullYear(); + + if (year !== now.getUTCFullYear()) return `${day} ${month} ${year}`; + return `${day} ${month}`; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — 9 tests in `relative.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/time +git commit -m "feat(time): relative timestamp formatting" +``` + +--- + +## Task 2: Activity humanising + +Turns an audit row into a sentence a client can read. Pure, test-first. + +**Files:** +- Create: `src/lib/activity/format.ts` +- Test: `src/lib/activity/format.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`src/lib/activity/format.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { describeActivity } from "@/lib/activity/format"; +import type { ActivityRecord } from "@/lib/pb/types"; + +function row(overrides: Partial): ActivityRecord { + return { + id: "a1", + created: "2026-09-16 10:00:00.000Z", + project: "p1", + card: "c1", + actor: "u1", + action: "card_updated", + field: "", + old_value: "", + new_value: "", + ...overrides, + } as ActivityRecord; +} + +describe("describeActivity", () => { + it("describes a priority change in words, not codes", () => { + expect( + describeActivity( + row({ action: "priority_changed", field: "priority", old_value: "medium", new_value: "high" }) + ) + ).toBe("changed priority from Medium to High"); + }); + + it("describes setting a target date that had none", () => { + expect( + describeActivity( + row({ action: "date_changed", field: "target_date", old_value: "", new_value: "2026-10-01" }) + ) + ).toBe("set the target date to 1 Oct 2026"); + }); + + it("describes moving a target date", () => { + expect( + describeActivity( + row({ + action: "date_changed", + field: "target_date", + old_value: "2026-10-01 00:00:00.000Z", + new_value: "2026-11-15", + }) + ) + ).toBe("moved the target date from 1 Oct 2026 to 15 Nov 2026"); + }); + + it("describes clearing a target date", () => { + expect( + describeActivity( + row({ action: "date_changed", field: "target_date", old_value: "2026-10-01", new_value: "" }) + ) + ).toBe("cleared the target date"); + }); + + it("describes card creation, acceptance and archiving", () => { + expect(describeActivity(row({ action: "card_created", new_value: "Add a POS system" }))) + .toBe("added this card"); + expect(describeActivity(row({ action: "card_accepted" }))).toBe("accepted this request"); + expect(describeActivity(row({ action: "card_archived" }))).toBe("archived this card"); + }); + + it("names the destination when a card moves", () => { + expect(describeActivity(row({ action: "card_moved", new_value: "In Progress" }))) + .toBe("moved this card to In Progress"); + }); + + it("falls back gracefully when a move has no destination recorded", () => { + expect(describeActivity(row({ action: "card_moved", new_value: "" }))) + .toBe("moved this card"); + }); + + it("describes comments and subtasks", () => { + expect(describeActivity(row({ action: "comment_added" }))).toBe("commented"); + expect( + describeActivity(row({ action: "subtask_toggled", new_value: "true", field: "Wire up the till" })) + ).toBe("completed “Wire up the till”"); + expect( + describeActivity(row({ action: "subtask_toggled", new_value: "false", field: "Wire up the till" })) + ).toBe("reopened “Wire up the till”"); + }); + + it("describes a generic update", () => { + expect(describeActivity(row({ action: "card_updated", field: "description" }))) + .toBe("updated the description"); + expect(describeActivity(row({ action: "card_updated", field: "title" }))) + .toBe("renamed this card"); + }); + + it("never returns an empty string for an unknown action", () => { + // Double assertion on purpose: the point is to simulate an action value + // added by a later phase that this switch has not learned yet. + const unknown = "something_new" as unknown as ActivityRecord["action"]; + const result = describeActivity(row({ action: unknown })); + expect(result.length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/activity/format"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/activity/format.ts`: + +```ts +/** + * Renders an audit row as a sentence. + * + * The audience is a client manager, not a developer. Values are written the + * way a person would say them — "High", not "high"; "1 Oct 2026", not an ISO + * timestamp — because this timeline is the record they read when they want to + * know what changed and when. + * + * The actor's name is prepended by the caller, so every string here starts + * with a verb and reads as " changed priority from Medium to High". + */ + +import type { ActivityRecord } from "@/lib/pb/types"; + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +const PRIORITY_LABELS: Record = { + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", +}; + +function label(value: string): string { + return PRIORITY_LABELS[value] ?? value; +} + +/** "2026-10-01" or "2026-10-01 00:00:00.000Z" -> "1 Oct 2026". */ +function longDate(value: string): string { + if (!value) return ""; + const year = Number(value.slice(0, 4)); + const month = MONTHS[Number(value.slice(5, 7)) - 1]; + const day = Number(value.slice(8, 10)); + if (!month || Number.isNaN(day)) return value; + return `${day} ${month} ${year}`; +} + +export function describeActivity(row: ActivityRecord): string { + switch (row.action) { + case "priority_changed": + return `changed priority from ${label(row.old_value)} to ${label(row.new_value)}`; + + case "date_changed": { + const from = longDate(row.old_value); + const to = longDate(row.new_value); + if (!from && to) return `set the target date to ${to}`; + if (from && !to) return "cleared the target date"; + return `moved the target date from ${from} to ${to}`; + } + + case "card_created": + return "added this card"; + + case "card_accepted": + return "accepted this request"; + + case "card_archived": + return "archived this card"; + + case "card_moved": + return row.new_value + ? `moved this card to ${row.new_value}` + : "moved this card"; + + case "comment_added": + return "commented"; + + case "subtask_toggled": + return row.new_value === "true" + ? `completed “${row.field}”` + : `reopened “${row.field}”`; + + case "card_updated": + if (row.field === "title") return "renamed this card"; + if (row.field) return `updated the ${row.field}`; + return "updated this card"; + + default: + // An action added later must still render as something. A blank line in + // a history the client reads is worse than a vague one. + return "made a change"; + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — 11 tests in `format.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/activity/format.ts src/lib/activity/format.test.ts +git commit -m "feat(activity): render audit rows as readable sentences" +``` + +--- + +## Task 3: The activity logging helper + +**Files:** +- Create: `src/app/actions/activity.ts` + +- [ ] **Step 1: Write the helper** + +`src/app/actions/activity.ts`: + +```ts +import "server-only"; + +import { getAdminPb } from "@/lib/pb/admin"; +import type { ActivityAction } from "@/lib/pb/types"; + +/** + * Write one audit row. + * + * Uses a superuser connection deliberately. `activity.createRule` is null, + * which in PocketBase means superuser-only — the owner's own token is refused, + * so a Server Action cannot insert here with the caller's connection. Without + * this helper the surrounding mutation succeeds and its history silently + * vanishes, which is the failure the audit log exists to prevent. + * + * Never call this before verifying the caller is allowed to make the change + * being logged. It checks nothing. + */ +export async function logActivity(entry: { + project: string; + card?: string; + actor: string; + action: ActivityAction; + field?: string; + oldValue?: string; + newValue?: string; +}): Promise { + const pb = await getAdminPb(); + + await pb.collection("activity").create({ + project: entry.project, + card: entry.card ?? "", + actor: entry.actor, + action: entry.action, + field: entry.field ?? "", + old_value: entry.oldValue ?? "", + new_value: entry.newValue ?? "", + }); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/actions/activity.ts +git commit -m "feat(activity): superuser-backed audit log helper" +``` + +--- + +## Task 4: Card detail loader + +**Files:** +- Create: `src/lib/data/card.ts` + +- [ ] **Step 1: Write the loader** + +`src/lib/data/card.ts`: + +```ts +import "server-only"; + +import { getServerPb } from "@/lib/pb/server"; +import type { + ActivityRecord, + CardRecord, + ClientRecord, + ColumnRecord, + CommentRecord, + ProjectRecord, + SubtaskRecord, + UserRecord, +} from "@/lib/pb/types"; + +export interface CardDetail { + card: CardRecord; + column: ColumnRecord; + project: ProjectRecord; + client: ClientRecord; + columns: ColumnRecord[]; + subtasks: SubtaskRecord[]; + comments: CommentRecord[]; + activity: ActivityRecord[]; + /** Display names by user id, for comments and the timeline. */ + names: Record; +} + +/** + * Everything the card detail page needs, read with the caller's own token so + * the Plan 1a read rules do the scoping. + * + * Returns null when the card does not exist OR the caller may not see it. The + * page renders the same 404 for both, so a client cannot discover that another + * client's card is real by probing ids. + */ +export async function loadCardDetail( + clientSlug: string, + projectSlug: string, + cardId: string +): Promise { + const pb = await getServerPb(); + if (!pb.authStore.isValid) return null; + + try { + const client = await pb + .collection("clients") + .getFirstListItem(`slug = "${clientSlug}"`); + + const project = await pb + .collection("projects") + .getFirstListItem( + `slug = "${projectSlug}" && client = "${client.id}"` + ); + + const card = await pb.collection("cards").getOne(cardId); + + // The card must belong to the project in the URL. Without this check a + // valid card id from another project would render under this project's + // breadcrumb — and for a client manager, the read rules would have already + // allowed it if both projects are theirs. + if (card.project !== project.id) return null; + + const columns = await pb.collection("columns").getFullList({ + filter: `project = "${project.id}"`, + sort: "position", + }); + + const column = columns.find((c) => c.id === card.column); + if (!column) return null; + + const subtasks = await pb.collection("subtasks").getFullList({ + filter: `card = "${cardId}"`, + sort: "position", + }); + + const comments = await pb.collection("comments").getFullList({ + filter: `card = "${cardId}"`, + sort: "created", + }); + + const activity = await pb.collection("activity").getFullList({ + filter: `card = "${cardId}"`, + sort: "-created", + }); + + const userIds = new Set(); + for (const c of comments) if (c.author) userIds.add(c.author); + for (const a of activity) if (a.actor) userIds.add(a.actor); + if (card.created_by) userIds.add(card.created_by); + + const names: Record = {}; + for (const id of userIds) { + const user = await pb + .collection("users") + .getOne(id) + .catch(() => null); + names[id] = user?.name || user?.email || "Someone"; + } + + return { + card, + column, + project, + client, + columns, + subtasks, + comments, + activity, + names, + }; + } catch { + return null; + } +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/data/card.ts +git commit -m "feat(data): card detail loader scoped by read rules" +``` + +--- + +## Task 5: Owner card actions + +**Files:** +- Create: `src/app/actions/cards.ts` + +- [ ] **Step 1: Write the actions** + +`src/app/actions/cards.ts`: + +```ts +"use server"; + +import { revalidatePath } from "next/cache"; + +import { getServerPb } from "@/lib/pb/server"; +import { requireSession } from "@/lib/auth/session"; +import { logActivity } from "@/app/actions/activity"; +import { positionBetween } from "@/lib/ordering/fractional"; +import type { CardRecord, ColumnRecord, ProjectRecord } from "@/lib/pb/types"; + +/** + * Owner mutations. + * + * SECURITY: a Server Action is a public HTTP endpoint. Not rendering the + * button that calls it protects nothing. Every action below re-checks the + * session and the role before touching anything, and PocketBase's owner-only + * write rules refuse the request a second time if one of them is ever missed. + * + * These use the caller's OWN PocketBase token — no superuser — precisely so + * that second check is real. The single exception is logActivity, which needs + * a superuser because activity.createRule is superuser-only. + */ + +async function requireOwner() { + const session = await requireSession(); + if (session.role !== "owner") { + throw new Error("Not found"); + } + return session; +} + +function boardPath(clientSlug: string, projectSlug: string): string { + return `/p/${clientSlug}/${projectSlug}`; +} + +export async function createCard(input: { + projectId: string; + columnId: string; + title: string; + clientSlug: string; + projectSlug: string; +}): Promise<{ id: string }> { + const session = await requireOwner(); + const pb = await getServerPb(); + + const title = input.title.trim().slice(0, 200); + if (!title) throw new Error("A title is required"); + + const last = await pb + .collection("cards") + .getFirstListItem(`column = "${input.columnId}"`, { + sort: "-position", + }) + .catch(() => null); + + const card = await pb.collection("cards").create({ + project: input.projectId, + column: input.columnId, + title, + description: "", + position: positionBetween(last ? last.position : null, null), + priority: "medium", + created_by: session.userId, + created_by_role: "owner", + accepted: true, + archived: false, + }); + + await logActivity({ + project: input.projectId, + card: card.id, + actor: session.userId, + action: "card_created", + newValue: title, + }); + + revalidatePath(boardPath(input.clientSlug, input.projectSlug)); + return { id: card.id }; +} + +export async function updateCard(input: { + cardId: string; + title?: string; + description?: string; + scopeRule?: string; + clientSlug: string; + projectSlug: string; +}): Promise { + const session = await requireOwner(); + const pb = await getServerPb(); + + const card = await pb.collection("cards").getOne(input.cardId); + + const patch: Record = {}; + if (input.title !== undefined) { + const title = input.title.trim().slice(0, 200); + if (!title) throw new Error("A title is required"); + patch.title = title; + } + if (input.description !== undefined) { + patch.description = input.description.slice(0, 50000); + } + if (input.scopeRule !== undefined) { + patch.scope_rule = input.scopeRule.trim().slice(0, 60); + } + + if (Object.keys(patch).length === 0) return; + + await pb.collection("cards").update(input.cardId, patch); + + // One row per field, matching how client edits are logged, so the timeline + // reads consistently regardless of who made the change. + for (const field of Object.keys(patch)) { + if (field === "scope_rule") continue; // internal, not client-facing + await logActivity({ + project: card.project, + card: card.id, + actor: session.userId, + action: "card_updated", + field: field === "title" ? "title" : "description", + }); + } + + revalidatePath(boardPath(input.clientSlug, input.projectSlug)); + revalidatePath(`${boardPath(input.clientSlug, input.projectSlug)}/c/${input.cardId}`); +} + +/** + * Accept a client's request off the Inbox. + * + * Sets `accepted` and stamps `start_date`. The start date is not decoration: + * Phase 2 uses it to bound which commits a scope rule may claim, so a new card + * cannot retroactively swallow a year of old commits sharing its scope. + * + * The card moves to the column immediately after the Inbox — Backlog in the + * default layout — rather than to a hardcoded name, so a renamed board still + * works. + */ +export async function acceptCard(input: { + cardId: string; + clientSlug: string; + projectSlug: string; +}): Promise { + const session = await requireOwner(); + const pb = await getServerPb(); + + const card = await pb.collection("cards").getOne(input.cardId); + + const columns = await pb.collection("columns").getFullList({ + filter: `project = "${card.project}"`, + sort: "position", + }); + + const inboxIndex = columns.findIndex((c) => c.is_inbox); + const destination = columns[inboxIndex + 1] ?? columns[inboxIndex]; + if (!destination) throw new Error("This project has no column to accept into"); + + const last = await pb + .collection("cards") + .getFirstListItem(`column = "${destination.id}"`, { + sort: "-position", + }) + .catch(() => null); + + const today = new Date().toISOString().slice(0, 10); + + await pb.collection("cards").update(input.cardId, { + accepted: true, + start_date: card.start_date || today, + column: destination.id, + position: positionBetween(last ? last.position : null, null), + }); + + await logActivity({ + project: card.project, + card: card.id, + actor: session.userId, + action: "card_accepted", + newValue: destination.name, + }); + + revalidatePath(boardPath(input.clientSlug, input.projectSlug)); +} + +export async function archiveCard(input: { + cardId: string; + clientSlug: string; + projectSlug: string; +}): Promise { + const session = await requireOwner(); + const pb = await getServerPb(); + + const card = await pb.collection("cards").getOne(input.cardId); + + await pb.collection("cards").update(input.cardId, { archived: true }); + + await logActivity({ + project: card.project, + card: card.id, + actor: session.userId, + action: "card_archived", + }); + + revalidatePath(boardPath(input.clientSlug, input.projectSlug)); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/actions/cards.ts +git commit -m "feat(actions): owner card create, update, accept and archive" +``` + +--- + +## Task 6: Subtask and comment actions + +**Files:** +- Create: `src/app/actions/subtasks.ts` +- Create: `src/app/actions/comments.ts` + +- [ ] **Step 1: Write the subtask actions** + +`src/app/actions/subtasks.ts`: + +```ts +"use server"; + +import { revalidatePath } from "next/cache"; + +import { getServerPb } from "@/lib/pb/server"; +import { requireSession } from "@/lib/auth/session"; +import { logActivity } from "@/app/actions/activity"; +import { positionBetween } from "@/lib/ordering/fractional"; +import type { CardRecord, SubtaskRecord } from "@/lib/pb/types"; + +/** See the security note at the top of src/app/actions/cards.ts. */ +async function requireOwner() { + const session = await requireSession(); + if (session.role !== "owner") throw new Error("Not found"); + return session; +} + +function cardPath(clientSlug: string, projectSlug: string, cardId: string) { + return `/p/${clientSlug}/${projectSlug}/c/${cardId}`; +} + +export async function addSubtask(input: { + cardId: string; + title: string; + clientSlug: string; + projectSlug: string; +}): Promise { + await requireOwner(); + const pb = await getServerPb(); + + const title = input.title.trim().slice(0, 200); + if (!title) return; + + const last = await pb + .collection("subtasks") + .getFirstListItem(`card = "${input.cardId}"`, { + sort: "-position", + }) + .catch(() => null); + + await pb.collection("subtasks").create({ + card: input.cardId, + title, + done: false, + position: positionBetween(last ? last.position : null, null), + }); + + revalidatePath(cardPath(input.clientSlug, input.projectSlug, input.cardId)); +} + +export async function toggleSubtask(input: { + subtaskId: string; + done: boolean; + clientSlug: string; + projectSlug: string; +}): Promise { + const session = await requireOwner(); + const pb = await getServerPb(); + + const subtask = await pb + .collection("subtasks") + .getOne(input.subtaskId); + + await pb.collection("subtasks").update(input.subtaskId, { done: input.done }); + + const card = await pb.collection("cards").getOne(subtask.card); + + await logActivity({ + project: card.project, + card: card.id, + actor: session.userId, + action: "subtask_toggled", + // `field` carries the subtask's title so the timeline can name it without + // a second lookup, and still reads correctly after the subtask is deleted. + field: subtask.title, + newValue: String(input.done), + }); + + revalidatePath(cardPath(input.clientSlug, input.projectSlug, subtask.card)); +} + +export async function deleteSubtask(input: { + subtaskId: string; + clientSlug: string; + projectSlug: string; +}): Promise { + await requireOwner(); + const pb = await getServerPb(); + + const subtask = await pb + .collection("subtasks") + .getOne(input.subtaskId); + + await pb.collection("subtasks").delete(input.subtaskId); + + revalidatePath(cardPath(input.clientSlug, input.projectSlug, subtask.card)); +} +``` + +- [ ] **Step 2: Write the comment action** + +`src/app/actions/comments.ts`: + +```ts +"use server"; + +import { revalidatePath } from "next/cache"; + +import { getServerPb } from "@/lib/pb/server"; +import { requireSession } from "@/lib/auth/session"; +import { logActivity } from "@/app/actions/activity"; +import type { CardRecord } from "@/lib/pb/types"; + +/** + * The OWNER's comment path only. + * + * A client manager comments through POST /api/client/cards/:id/comments, built + * in Plan 1a. Do not route them here: that route verifies the card belongs to + * their client and runs under a superuser with an explicit whitelist, which is + * the boundary this action deliberately does not have. + */ +export async function addOwnerComment(input: { + cardId: string; + body: string; + clientSlug: string; + projectSlug: string; +}): Promise { + const session = await requireSession(); + if (session.role !== "owner") throw new Error("Not found"); + + const body = input.body.trim().slice(0, 20000); + if (!body) return; + + const pb = await getServerPb(); + const card = await pb.collection("cards").getOne(input.cardId); + + await pb.collection("comments").create({ + card: input.cardId, + author: session.userId, + body, + }); + + await logActivity({ + project: card.project, + card: card.id, + actor: session.userId, + action: "comment_added", + }); + + revalidatePath( + `/p/${input.clientSlug}/${input.projectSlug}/c/${input.cardId}` + ); +} +``` + +- [ ] **Step 3: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/actions +git commit -m "feat(actions): subtask and owner comment mutations" +``` + +--- + +## Task 7: Subtask list component + +**Files:** +- Create: `src/components/card/subtask-list.tsx` + +- [ ] **Step 1: Write the component** + +`src/components/card/subtask-list.tsx`: + +```tsx +"use client"; + +import { useState, useTransition } from "react"; +import { Plus, X } from "lucide-react"; + +import { addSubtask, deleteSubtask, toggleSubtask } from "@/app/actions/subtasks"; +import { cn } from "@/lib/cn"; +import type { SubtaskRecord } from "@/lib/pb/types"; + +export function SubtaskList({ + cardId, + subtasks, + editable, + clientSlug, + projectSlug, +}: { + cardId: string; + subtasks: SubtaskRecord[]; + editable: boolean; + clientSlug: string; + projectSlug: string; +}) { + const [pending, startTransition] = useTransition(); + const [draft, setDraft] = useState(""); + + const done = subtasks.filter((s) => s.done).length; + const percent = subtasks.length + ? Math.round((done / subtasks.length) * 100) + : 0; + + function submit(event: React.FormEvent) { + event.preventDefault(); + const title = draft.trim(); + if (!title) return; + setDraft(""); + startTransition(() => + addSubtask({ cardId, title, clientSlug, projectSlug }).then(() => undefined) + ); + } + + return ( +
+
+

+ Checklist +

+ {subtasks.length > 0 && ( + <> + + {done}/{subtasks.length} + +
+
+
+ + )} +
+ + {subtasks.length === 0 && !editable && ( +

No checklist items yet.

+ )} + +
    + {subtasks.map((subtask) => ( +
  • + + startTransition(() => + toggleSubtask({ + subtaskId: subtask.id, + done: event.target.checked, + clientSlug, + projectSlug, + }).then(() => undefined) + ) + } + className="h-4 w-4 shrink-0 rounded border-border-strong accent-[var(--brand)]" + /> + + {subtask.title} + + {editable && ( + + )} +
  • + ))} +
+ + {editable && ( +
+ + setDraft(event.target.value)} + placeholder="Add an item" + disabled={pending} + className="flex-1 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted" + /> + + )} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/card/subtask-list.tsx +git commit -m "feat(card): checklist with progress and owner editing" +``` + +The build will not be exercised until Task 10 renders this. That is expected. + +--- + +## Task 8: Comment thread + +**Files:** +- Create: `src/components/card/comment-thread.tsx` + +**The two paths.** The owner posts through the `addOwnerComment` Server Action; a client manager posts through `POST /api/client/cards/:id/comments`. Both exist for good reasons — see the header comment in `src/app/actions/comments.ts`. This component picks the right one from the role and must not be "simplified" into a single path. + +- [ ] **Step 1: Write the component** + +`src/components/card/comment-thread.tsx`: + +```tsx +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { addOwnerComment } from "@/app/actions/comments"; +import { relativeTime } from "@/lib/time/relative"; +import type { CommentRecord, Role } from "@/lib/pb/types"; + +export function CommentThread({ + cardId, + comments, + names, + role, + clientSlug, + projectSlug, +}: { + cardId: string; + comments: CommentRecord[]; + names: Record; + role: Role; + clientSlug: string; + projectSlug: string; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [draft, setDraft] = useState(""); + const [error, setError] = useState(null); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + const body = draft.trim(); + if (!body) return; + + setError(null); + + if (role === "owner") { + setDraft(""); + startTransition(() => + addOwnerComment({ cardId, body, clientSlug, projectSlug }).then( + () => undefined + ) + ); + return; + } + + // Client managers post through the hardened route, not the action. + const response = await fetch(`/api/client/cards/${cardId}/comments`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ body }), + }); + + if (!response.ok) { + setError("Could not post that comment"); + return; + } + + setDraft(""); + router.refresh(); + } + + return ( +
+

+ Comments +

+ + {comments.length === 0 && ( +

No comments yet.

+ )} + +
    + {comments.map((comment) => ( +
  • +
    + + {names[comment.author] ?? "Someone"} + + + {relativeTime(comment.created)} + +
    + {/* + Rendered as plain text on purpose. The body is markdown authored + by a client and is never passed to dangerouslySetInnerHTML. + whitespace-pre-wrap preserves their line breaks without + interpreting anything. + */} +

    + {comment.body} +

    +
  • + ))} +
+ +
+