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 7e84722..60f55a4 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 @@ -2101,252 +2101,105 @@ git commit -m "feat(board): owner drag and drop with optimistic persistence" --- -## Task 11b: REMOVED — superseded - -The client control component moved into Task 9, where it is created alongside the other presentation components so that Task 11's board compiles. The code block below is the same file and is retained only so a reader following an older copy of this plan does not think a step is missing. **Do not create this file twice.** - -
-Duplicate of the Task 9 Step 3 file — skip - -`src/components/board/client-card-controls.tsx`: - -```tsx -"use client"; - -import { useState } from "react"; - -import { - Dropdown, - DropdownContent, - DropdownItem, - DropdownLabel, - DropdownTrigger, -} from "@/components/ui/dropdown"; -import type { CardRecord, Priority } from "@/lib/pb/types"; - -const PRIORITIES: Array<{ value: Priority; label: string }> = [ - { value: "critical", label: "Critical" }, - { value: "high", label: "High" }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, -]; - -/** - * The only two fields a client manager may change. - * - * Both go through PATCH /api/client/cards/:id, never straight to PocketBase. - * Their token cannot write to the cards collection at all — see Plan 1a — so - * a direct SDK call from here would simply fail. The route is the path. - */ -export function ClientCardControls({ - card, - onPatched, -}: { - card: CardRecord; - onPatched: (card: CardRecord) => void; -}) { - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - async function patch(body: Record) { - setSaving(true); - setError(null); - - const previous = { ...card }; - onPatched({ ...card, ...body } as CardRecord); - - const response = await fetch(`/api/client/cards/${card.id}`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - - setSaving(false); - - if (!response.ok) { - onPatched(previous); - setError("Could not save"); - return; - } - - const { card: updated } = await response.json(); - onPatched({ ...card, ...updated } as CardRecord); - } - - const currentPriority = - PRIORITIES.find((p) => p.value === card.priority) ?? PRIORITIES[2]; - - return ( -
- - - - - - Priority - {PRIORITIES.map((option) => ( - patch({ priority: option.value })} - > - {option.label} - - ))} - - - - - - {error && ( - - {error} - - )} -
- ); -} -``` - -- [ ] **Step 2: Verify manually** - -Sign in as `manager@wf.example.com`, open the board, change a card's priority and set a target date. - -Expected: the pill updates immediately, and the change survives a reload. Set a date in the past and confirm the date pill turns accent-coloured. In the PocketBase admin UI, confirm two new `activity` rows exist with the correct old and new values. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/board/client-card-controls.tsx -git commit -m "feat(board): client priority and target date controls" -``` - ---- - -## Task 12: Realtime updates +## Task 12: Refresh on window focus **Files:** -- Create: `src/components/board/use-board-realtime.ts` +- Create: `src/components/board/use-refresh-on-focus.ts` - Modify: `src/components/board/board.tsx` -- [ ] **Step 1: Write the hook** +**Why not PocketBase realtime.** Live subscriptions need an authenticated +PocketBase connection *in the browser*, and the auth token lives in an httpOnly +cookie that JavaScript cannot read. Making it readable would hand a long-lived +token to any XSS on the page, for a convenience feature. -`src/components/board/use-board-realtime.ts`: +So this plan refreshes the board when the window regains focus instead. It +covers the case that actually matters — the owner tabs back after working and +sees the client's overnight priority changes — without weakening the token. True +realtime is a Plan 1c decision, taken deliberately with that tradeoff on the +table rather than smuggled in here. + +- [ ] **Step 1: Write the hook** ```ts "use client"; import { useEffect } from "react"; - -import { pbBrowser } from "@/lib/pb/browser"; -import type { CardRecord } from "@/lib/pb/types"; +import { useRouter } from "next/navigation"; /** - * Keep a board in sync with other sessions. + * Re-fetch the server component tree when the tab regains focus. * - * The subscription is scoped by the same read rules as every other query, so a - * client manager receives events only for their own client's cards — the - * filter below is a bandwidth optimisation, not the access control. + * `router.refresh()` re-runs the page's server components, so the board is + * reloaded through the same read rules as the first render. Nothing is + * fetched client-side and no token is needed. * - * Local edits echo back as events. Applying them is harmless: the record we - * receive is the record we just wrote. + * Skipped while `paused` is true, so a refresh cannot land mid-drag and pull + * the card out from under the pointer. */ -export function useBoardRealtime( - projectId: string, - onChange: (action: string, card: CardRecord) => void -) { - useEffect(() => { - let cancelled = false; +export function useRefreshOnFocus(paused: boolean) { + const router = useRouter(); - const promise = pbBrowser - .collection("cards") - .subscribe( - "*", - (event) => { - if (cancelled) return; - if (event.record.project !== projectId) return; - onChange(event.action, event.record); - }, - { filter: `project = "${projectId}"` } - ); + useEffect(() => { + if (paused) return; + + function onFocus() { + if (document.visibilityState === "visible") router.refresh(); + } + + window.addEventListener("visibilitychange", onFocus); + window.addEventListener("focus", onFocus); return () => { - cancelled = true; - promise.then(() => pbBrowser.collection("cards").unsubscribe("*")).catch(() => {}); + window.removeEventListener("visibilitychange", onFocus); + window.removeEventListener("focus", onFocus); }; - }, [projectId, onChange]); + }, [paused, router]); } ``` +Create it at `src/components/board/use-refresh-on-focus.ts`. + - [ ] **Step 2: Wire it into the board** In `src/components/board/board.tsx`, add the import: ```tsx -import { useCallback } from "react"; -import { useBoardRealtime } from "@/components/board/use-board-realtime"; +import { useRefreshOnFocus } from "@/components/board/use-refresh-on-focus"; ``` Then inside `Board`, immediately after the `activeCard` line, add: ```tsx - const onRemoteChange = useCallback( - (action: string, record: CardRecord) => { - setCards((current) => { - if (action === "delete") { - return current.filter((c) => c.id !== record.id); - } - // A card being dragged locally must not be yanked out from under the - // pointer by an echo of our own optimistic write. - if (record.id === activeId) return current; - - const exists = current.some((c) => c.id === record.id); - if (!exists) { - return record.archived ? current : [...current, record]; - } - return current.map((c) => (c.id === record.id ? record : c)); - }); - }, - [activeId] - ); - - useBoardRealtime(projectId, onRemoteChange); + useRefreshOnFocus(activeId !== null); ``` -The `data-project={projectId}` attribute on the owner branch's wrapper can stay; `projectId` is now used by the hook in both branches. +A server refresh replaces `initialCards`, but `cards` is `useState` and will not +pick that up on its own. Add this effect directly below it so a refresh actually +reaches the board: + +```tsx + useEffect(() => { + if (activeId === null) setCards(initialCards); + }, [initialCards, activeId]); +``` + +and extend the React import at the top of the file to `import { useEffect, useMemo, useState } from "react";`. - [ ] **Step 3: Verify manually** -Open the same board in two browser windows, signed in as the owner in one and the client manager in the other. Drag a card in the owner's window. +Open the board as the owner in one window and as the client manager in another. +Change a priority in the manager's window, then click into the owner's window. -Expected: it moves in the manager's window within a second, without a reload. +Expected: the owner's board shows the new priority within a moment of regaining +focus, without a manual reload. Start a drag and confirm nothing jumps. - [ ] **Step 4: Commit** ```bash git add src/components/board -git commit -m "feat(board): realtime card updates across sessions" +git commit -m "feat(board): refresh the board when the window regains focus" ``` ---- - ## Task 13: Full verification - [ ] **Step 1: Run every test** diff --git a/src/app/api/client/cards/[id]/route.ts b/src/app/api/client/cards/[id]/route.ts new file mode 100644 index 0000000..2e2fb9d --- /dev/null +++ b/src/app/api/client/cards/[id]/route.ts @@ -0,0 +1,99 @@ +import { getAdminPb } from "@/lib/pb/admin"; +import { requireSession } from "@/lib/auth/session"; +import { + AccessError, + assertClientOwnsProject, + canWriteClientFields, +} from "@/lib/auth/access"; +import { pickClientCardPatch } from "@/lib/cards/client-patch"; +import { buildActivityRows } from "@/lib/activity/diff"; +import { jsonError, jsonOk } from "@/lib/http/json"; +import type { CardRecord, ProjectRecord } from "@/lib/pb/types"; + +/** + * PATCH /api/client/cards/:id — a client manager sets priority or target_date. + * + * This route holds a superuser token, so its own checks are the only thing + * between a client and every field on the card. In order: + * 1. a valid session + * 2. the card exists, and its project belongs to this session's client + * 3. the caller is a client_manager for that client + * 4. the body is reduced to the two whitelisted fields + * 5. audit rows are written for whatever actually changed + * + * Do not reorder these, and do not add an early return that skips step 5. + */ +export async function PATCH( + 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 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); + + // Throws 404 rather than 403: a 403 would confirm the card exists to + // someone who should not know that. + assertClientOwnsProject(session, project); + + if (!canWriteClientFields(session, project.client)) { + return jsonError("Card not found", 404); + } + + const { patch, dropped } = pickClientCardPatch(body); + + if (dropped.length > 0) { + console.warn( + `[client-patch] user=${session.userId} card=${id} dropped=${dropped.join(",")}` + ); + } + + if (Object.keys(patch).length === 0) { + return jsonError("No editable fields in request", 400); + } + + const rows = buildActivityRows(card, patch, session.userId); + + const updated = await pb + .collection("cards") + .update(id, patch); + + for (const row of rows) { + await pb.collection("activity").create(row); + } + + return jsonOk({ + card: { + id: updated.id, + priority: updated.priority, + target_date: updated.target_date, + }, + changes: rows.length, + }); + } catch (error) { + if (error instanceof AccessError) { + return jsonError(error.message, error.status); + } + console.error("[client-patch] unexpected error", error); + return jsonError("Something went wrong", 500); + } +} \ No newline at end of file