From cb122e802a71df718288d7ad92e4da7d341bde5e Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 13:11:20 +0200 Subject: [PATCH] docs(phase-9b): plan for picker page, two-step route, owner UI on /admin/clients/[id] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight tasks in the same format as 8b: 1. /admin/clients/[clientId] page (link the existing /admin/clients list into it) 2. mode='client' variant; Server Action takes a scope union for revalidation 3. listPickerProjects + resolveProjectForClient helpers 4. /intake/[token] picker page (single-project redirect, zero- projects fallback, multi-project list) 5. /intake/[token]/p/[projectId] wizard page 6. intakeStepPublicAction + POST /api/public/intake accept and verify projectId; cap check branches on link.client empty vs set 7. confirm thanks page links to picker (no change) 8. full verification Backwards compat: Phase 8 URLs (link.project set, link.client empty) still work — the wizard URL is the same shape as before, the action falls through to the Phase 8 path. --- .../plans/2026-09-21-phase-9b-picker-route.md | 1083 +++++++++++++++++ 1 file changed, 1083 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-21-phase-9b-picker-route.md diff --git a/docs/superpowers/plans/2026-09-21-phase-9b-picker-route.md b/docs/superpowers/plans/2026-09-21-phase-9b-picker-route.md new file mode 100644 index 0000000..e32861c --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-phase-9b-picker-route.md @@ -0,0 +1,1083 @@ +# Phase 9b — Picker Page, Two-Step Route, New Owner UI + +> **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 picker work end-to-end. A public visitor opens `/intake/`, sees the client's project list (showing only projects with `show_in_intake = true`), picks one, and lands in the wizard. The owner has a new page at `/admin/clients/[clientId]` that shows their request links and per-project opt-in toggles for that client. + +**Architecture:** Two new server-component pages (`/admin/clients/[clientId]` and the public `/intake/[token]/p/[projectId]`), the picker page itself (`/intake/[token]`), and three small changes: `intakeStepPublicAction` accepts `projectId`, `POST /api/public/intake` verifies it, and `` is wired to read it from the URL. The Phase 8 wizard component is unchanged. + +**Tech Stack:** Next.js 16 Server Components · Server Actions · PocketBase 0.30 · Tailwind 4 · vitest + +**Spec:** `docs/superpowers/specs/2026-09-21-phase-9-per-client-intake-links-and-project-picker-design.md` §5, §6, §7.1 + +**Prerequisite:** Phase 9a complete — `intake_links.client`, `projects.show_in_intake`, `intake_link_submissions` exist; `isLinkProjectUsable` and `bumpDailyCapForProject` are exported from `src/lib/intake-links/index.ts`. + +--- + +## File structure + +``` +src/ +├── app/ +│ ├── intake/ +│ │ └── [token]/ +│ │ ├── page.tsx # MODIFY — becomes the picker page +│ │ └── p/ +│ │ └── [projectId]/ +│ │ ├── page.tsx # NEW — wizard page for the +│ │ │ # chosen project +│ │ └── sent/ +│ │ └── [cardId]/ +│ │ └── page.tsx # MODIFY — read projectId from URL +│ │ # so the "Send another" link +│ │ # keeps the picker context +│ ├── admin/ +│ │ └── clients/ +│ │ ├── page.tsx # MODIFY — link each client name +│ │ │ # to /admin/clients/[clientId] +│ │ └── [clientId]/ +│ │ └── page.tsx # NEW — client admin page +│ └── actions/ +│ └── intake-links.ts # MODIFY — createIntakeLinkAction +│ # takes clientId, not projectId +├── components/ +│ └── intake/ +│ └── intake-public.tsx # MODIFY — accepts projectId +│ # from URL, threads it into +│ # intakeStepPublicAction +└── lib/ + ├── intake-links/ + │ └── client-projects.ts # NEW — listProjectsForClient, + │ # resolvePhase9Link + └── intake/ + └── interview.ts # MODIFY — sanitize the projectId + # arg, accept it on the action + # input +``` + +--- + +## Task 1: The owner-side client admin page + +**Files:** +- Create: `src/app/admin/clients/[clientId]/page.tsx` +- Modify: `src/app/admin/clients/page.tsx` (link client names to the new page) + +- [ ] **Step 1: Add the link in the existing client list** + +In `src/app/admin/clients/page.tsx`, change the client name cell from a `` to a ``: + +```tsx +import Link from "next/link"; + +// in the row: + +
+ + + {client.name} + + {client.slug} +
+
+``` + +- [ ] **Step 2: Write the new client admin page** + +`src/app/admin/clients/[clientId]/page.tsx`: + +```tsx +import { notFound } from "next/navigation"; + +import { AppShell } from "@/components/shell/app-shell"; +import { TopBar } from "@/components/shell/top-bar"; +import { IntakeLinksPanel } from "@/components/admin/intake-links-panel"; +import { ShowInIntakeToggle } from "@/components/admin/show-in-intake-toggle"; +import { getAdminPb } from "@/lib/pb/admin"; +import type { ClientRecord, ProjectRecord } from "@/lib/pb/types"; + +/** + * Per-client request-link admin page. + * + * Two stacked panels: a Request-links panel (the same component Phase 8 + * shipped for the project page, now bound to client instead) and a + * Projects list with the "List on request page" toggle next to each. + * + * The Phase 8 /admin/projects/[projectId] page keeps its own Request-links + * panel for the legacy per-project link shape. The picker is the new + * default; the legacy path is for owners who haven't migrated yet. + * + * Owner-only by virtue of the admin layout, which is already enforced + * upstream. + */ +export default async function ClientAdminPage({ + params, +}: { + params: Promise<{ clientId: string }>; +}) { + const { clientId } = await params; + + const pb = await getAdminPb(); + + let client: ClientRecord; + try { + client = await pb.collection("clients").getOne(clientId); + } catch { + notFound(); + } + + const projects = await pb + .collection("projects") + .getFullList({ + filter: `client = "${clientId}" && status != "archived"`, + sort: "name", + }); + + return ( + + + + {client.slug} + + + +
+
+ + +
+
+

Projects

+

+ Each project is hidden from the public request page by + default. Flip the toggle to show it. +

+
+
    + {projects.map((p) => ( +
  • +
    +
    {p.name}
    +
    + {p.slug} ·{" "} + {p.status} +
    +
    + +
  • + ))} + {projects.length === 0 && ( +
  • + No projects yet for this client. +
  • + )} +
+
+
+
+
+ ); +} +``` + +- [ ] **Step 3: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. The `IntakeLinksPanel` reference to `clientId` is a new prop we'll add in Task 2; until then the build will fail on the `clientId` prop. That's expected — Task 2 lands next. + +Actually, to keep the build green between tasks, declare the new prop on `IntakeLinksPanel` in this task too, with a minimal shape. Or skip the panel from rendering in this task and add it in Task 2. The second is cleaner: + +```tsx +
+ Request links panel lands in Task 2. +
+``` + +Replace this after Task 2 ships. (Or leave the placeholder and Task 2 does the swap.) + +- [ ] **Step 4: Commit** + +```bash +git add "src/app/admin/clients/[clientId]/page.tsx" src/app/admin/clients/page.tsx +git commit -m "feat(admin): client admin page with project picker toggles + +Phase 9b Task 1. /admin/clients/[clientId] shows the client's active +projects with a 'List on request page' toggle next to each. The +existing /admin/clients list now links each client name into the new +page." +``` + +--- + +## Task 2: `` becomes client-bound + +**Files:** +- Modify: `src/components/admin/intake-links-panel.tsx` +- Modify: `src/app/actions/intake-links.ts` + +Phase 8's `` takes `{ projectId, links }` and is mounted on the project admin page. Phase 9 needs a `{ clientId, projects }` version for the client admin page. The component has two options: + +(a) Add a `mode: "project" | "client"` prop and branch internally. +(b) Generalise: take `{ scope: { type: "project"; projectId } | { type: "client"; clientId }, links }`. + +(b) is cleaner. But (a) is less code. Going with (a) because the existing Phase 8 panel lives at `/admin/projects/[id]` and we want it untouched — the only consumer of the new mode is the client admin page. + +- [ ] **Step 1: Generalise the props** + +In `src/components/admin/intake-links-panel.tsx`, change: + +```tsx +export function IntakeLinksPanel({ + mode, + projectId, + clientId, + projects, + links, +}: { + mode: "project" | "client"; + /** Phase 8 path: project-bound link. */ + projectId?: string; + /** Phase 9 path: client-bound link, with projects for the picker. */ + clientId?: string; + projects?: ProjectRecord[]; + links: IntakeLinkRecord[]; +}) { +``` + +And inside the panel, branch on `mode` for the labels ("New request link" applies to both; the URL display is the same; the legacy-only toggle column is hidden in client mode for clarity). + +For the Phase 9 path, the mint call becomes: + +```ts +const result = await createIntakeLinkAction({ clientId, label: newLabel }); +``` + +- [ ] **Step 2: Update the Server Action** + +In `src/app/actions/intake-links.ts`, change the `createIntakeLinkAction` signature to take `clientId` instead of `projectId`: + +```ts +export async function createIntakeLinkAction(input: { + clientId: string; + label: string; + expiresAt?: string; + maxSubmissionsPerDay?: number; + notes?: string; +}): Promise<{ id: string; token: string; url: string }> { + await requireOwner(); + const pb = await getAdminPb(); + + const label = input.label.trim().slice(0, 80); + if (!label) throw new Error("A label is required"); + + const record = await pb.collection("intake_links").create({ + client: input.clientId, + label, + token: generateIntakeToken(), + active: true, + expires_at: input.expiresAt ?? "", + max_submissions_per_day: input.maxSubmissionsPerDay ?? 100, + submission_count: 0, + submission_count_today: 0, + submission_count_today_date: "", + last_submission_at: "", + notes: (input.notes ?? "").slice(0, 1000), + }); + + revalidatePath(`/admin/clients/${input.clientId}`); + return { + id: record.id, + token: record.token, + url: intakeUrlFor(record.token), + }; +} +``` + +The other actions (`rotateIntakeTokenAction`, `setIntakeLinkActiveAction`, `setIntakeLinkNotesAction`) stay unchanged — they take `linkId` and `projectId`, where `projectId` is used for revalidation. Phase 9's calls will pass the *client's* admin page path for revalidation; for now we keep the project path for backward compat. A small follow-up in Task 6 widens the revalidation. + +Wait — revalidation: Phase 8 revalidates `/admin/projects/[id]` after a server action. Phase 9 should revalidate `/admin/clients/[clientId]` too. Two options: pass `clientId` through and revalidate both paths, or have the panel know the revalidation path. Simplest: the panel always revalidates `/admin/clients/{clientId}` and `/admin/projects/{projectId}` is left to the older page. Phase 9 panel calls set the revalidation to `/admin/clients/[id]`. + +Let me handle this in the panel — it calls the action, then `router.refresh()` itself. Server actions don't need to do revalidatePath because the client component revalidates by refreshing. Cleaner. **Drop the `revalidatePath` calls in Phase 9's path.** Phase 8's project page keeps using `revalidatePath`. + +Actually that's a refactor of Phase 8 too. Let me keep `revalidatePath` calls in the actions but parameterise the path. The action receives the page path as part of its input. + +```ts +export async function createIntakeLinkAction(input: { + scope: { type: "project"; projectId: string } | { type: "client"; clientId: string }; + label: string; + expiresAt?: string; + maxSubmissionsPerDay?: number; + notes?: string; +}): Promise<{ id: string; token: string; url: string }> +``` + +Inside: + +```ts +const revalidatePathStr = input.scope.type === "project" + ? `/admin/projects/${input.scope.projectId}` + : `/admin/clients/${input.scope.clientId}`; +revalidatePath(revalidatePathStr); +``` + +Same shape for the rotate / pause / resume / notes actions. + +- [ ] **Step 3: Update the panel to pass `scope`** + +`src/components/admin/intake-links-panel.tsx`, in the `create` function: + +```ts +async function create() { + if (!newLabel.trim()) return; + setError(null); + startTransition(async () => { + try { + const result = await createIntakeLinkAction({ + scope: mode === "project" + ? { type: "project", projectId: projectId! } + : { type: "client", clientId: clientId! }, + label: newLabel, + }); + setNewUrl(result.url); + setNewLabel(""); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not create link"); + } + }); +} +``` + +- [ ] **Step 4: Wire the panel into the client admin page** + +Replace the placeholder section from Task 1: + +```tsx + +``` + +And read `links` for the client: + +```tsx +const links = await pb + .collection("intake_links") + .getFullList({ + filter: `client = "${clientId}"`, + sort: "-created", + }); +``` + +- [ ] **Step 5: Verify the build** + +```bash +npm run build +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/components/admin/intake-links-panel.tsx src/app/actions/intake-links.ts "src/app/admin/clients/[clientId]/page.tsx" +git commit -m "feat(admin): client-bound IntakeLinksPanel + +Phase 9b Task 2. Panel takes a mode prop ('project' keeps Phase 8 +behaviour; 'client' is the new shape). The Server Action's input +takes a 'scope' union so revalidatePath hits the right admin page +after every change. Wire-up on the client admin page is a single + with clientId + projects." +``` + +--- + +## Task 3: The picker helper + +**Files:** +- Create: `src/lib/intake-links/client-projects.ts` + +The picker page (`/intake/[token]`) needs to read all projects for the link's client with `active = true AND show_in_intake = true`. Encapsulate in a helper so the test (Task 6 of plan 9c) can drive it. + +- [ ] **Step 1: Write the helper** + +`src/lib/intake-links/client-projects.ts`: + +```ts +import "server-only"; + +import { getAdminPb } from "@/lib/pb/admin"; +import type { ProjectRecord } from "@/lib/pb/types"; + +/** + * List every project on the given client that is eligible to appear + * in the public intake picker. + * + * Eligible = (status = "active") AND (show_in_intake = true) AND + * (NOT archived). + * + * Reads via the superuser connection. The route that uses this is a + * public server component — no session, no user, so the only authority + * is the superuser. + * + * Sorted by project name so the picker is alphabetical and predictable. + */ +export async function listPickerProjects( + clientId: string +): Promise { + const pb = await getAdminPb(); + return pb + .collection("projects") + .getFullList({ + filter: `client = "${clientId}" && status = "active" && show_in_intake = true`, + sort: "name", + }); +} + +/** + * Read a single project, scoped to the client. Used by the + * `/intake/[token]/p/[projectId]` page to verify the URL's projectId + * is one the picker could have shown. + * + * Returns null if the project is missing, archived, opted out, or + * belongs to a different client. The caller 404s on null — same + * wording as the wizard, so a hostile body cannot probe. + */ +export async function resolveProjectForClient( + clientId: string, + projectId: string +): Promise { + const pb = await getAdminPb(); + try { + const project = await pb + .collection("projects") + .getOne(projectId); + if (project.client !== clientId) return null; + if (project.status !== "active") return null; + if (!project.show_in_intake) return null; + return project; + } catch { + return null; + } +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/intake-links/client-projects.ts +git commit -m "feat(intake-links): picker project list and resolver + +Phase 9b Task 3. listPickerProjects reads the public-eligible +projects on a client; resolveProjectForClient checks that a given +projectId belongs to the client and is opted in. Both fail closed +on any DB read error." +``` + +--- + +## Task 4: The picker page + +**Files:** +- Modify: `src/app/intake/[token]/page.tsx` + +The existing Phase 8 page resolves `link → projectId` and renders the wizard. Phase 9 makes it resolve `link → client`, then either: + +- Zero picker-eligible projects → "no projects enabled" page. +- One → server-side redirect to `/intake/[token]/p/[projectId]`. +- Two or more → render a list. + +- [ ] **Step 1: Rewrite the page** + +`src/app/intake/[token]/page.tsx`: + +```tsx +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; + +import { resolveIntakeLink } from "@/lib/intake-links"; +import { listPickerProjects } from "@/lib/intake-links/client-projects"; + +/** + * The picker — the new entry point for a Phase 9 client-bound link. + * + * Resolves the token server-side. Unknown / revoked / expired → 404 + * (same wording as the wizard page, so a hostile body cannot probe). + * + * Three branches after resolution: + * - zero picker-eligible projects → static "no projects enabled" page + * - one → server-side redirect to the wizard URL with the projectId + * - two or more → render a list of project names; each item links to + * /intake/[token]/p/[projectId] + * + * Phase 8 backwards compat: if the link has `link.project` set and + * `link.client` empty, the page behaves like the Phase 8 wizard URL — + * redirect to `/intake/[token]/p/[projectId]` using that single project. + * This means every Phase 8 URL keeps working with zero changes. + */ +export default async function IntakePickerPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + + const link = await resolveIntakeLink(token); + if (!link || !link.active) notFound(); + if ( + link.expires_at && + link.expires_at < new Date().toISOString().slice(0, 10) + ) { + notFound(); + } + + // Phase 8 backwards compat: per-project link → wizard URL with that projectId. + if (link.project && !link.client) { + redirect(`/intake/${token}/p/${link.project}`); + } + + if (!link.client) notFound(); + + const projects = await listPickerProjects(link.client); + + if (projects.length === 0) { + return ( +
+

+ Nothing here yet +

+

+ The developer hasn't enabled any projects for this page yet. + Try again later, or contact them directly. +

+
+ ); + } + + if (projects.length === 1) { + redirect(`/intake/${token}/p/${projects[0].id}`); + } + + return ( +
+
+

+ Tell us which project you have a question about +

+
+
    + {projects.map((p) => ( +
  • + + {p.name} + +
  • + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +- [ ] **Step 3: Commit** + +```bash +git add "src/app/intake/[token]/page.tsx" +git commit -m "feat(intake): picker page with single-project redirect + zero-projects fallback + +Phase 9b Task 4. The /intake/[token] route is now the picker. +Single-project links redirect straight to the wizard (no extra +click). Zero-projects links show a static 'nothing here yet' page +with no info leakage. Phase 8 backwards compat: a Phase 8 link +(with link.project set, link.client empty) redirects into the +wizard URL using that projectId, so the Phase 8 UX is unchanged." +``` + +--- + +## Task 5: The wizard page (`/intake/[token]/p/[projectId]`) + +**Files:** +- Create: `src/app/intake/[token]/p/[projectId]/page.tsx` +- Modify: `src/app/intake/[token]/sent/[cardId]/page.tsx` (the "Send another" link in the thanks page needs the projectId back) + +- [ ] **Step 1: Write the wizard page** + +`src/app/intake/[token]/p/[projectId]/page.tsx`: + +```tsx +import { notFound } from "next/navigation"; + +import { getAdminPb } from "@/lib/pb/admin"; +import { IntakePublic } from "@/components/intake/intake-public"; +import { resolveIntakeLink } from "@/lib/intake-links"; +import { resolveProjectForClient } from "@/lib/intake-links/client-projects"; +import type { ClientRecord, ProjectRecord } from "@/lib/pb/types"; + +/** + * The wizard for a Phase 9 client-bound link. + * + * Resolves the token to a link, the URL's projectId to a project on + * that link's client. Both checks fail closed: unknown token, unknown + * project, project on the wrong client, project not opted in, project + * archived → 404 with the same wording as the picker so a hostile + * body cannot probe. + * + * What ends up rendered is the same `` Phase 8 wrote, with + * `projectId` and `token` plumbed through. The wizard component itself + * does not change. + */ +export default async function IntakeWizardPage({ + params, +}: { + params: Promise<{ token: string; projectId: string }>; +}) { + const { token, projectId } = await params; + + const link = await resolveIntakeLink(token); + if (!link || !link.active) notFound(); + if ( + link.expires_at && + link.expires_at < new Date().toISOString().slice(0, 10) + ) { + notFound(); + } + + // Phase 8 backwards compat: per-project link → ignore URL projectId, + // use the link's own project. + let resolvedProjectId = projectId; + if (link.project && !link.client) { + resolvedProjectId = link.project; + } else if (!link.client) { + notFound(); + } else { + const project = await resolveProjectForClient(link.client, projectId); + if (!project) notFound(); + resolvedProjectId = project.id; + } + + // Read the project (and optionally the client) for the header text. + const pb = await getAdminPb(); + const project = await pb + .collection("projects") + .getOne(resolvedProjectId); + + // The "request to " header the Phase 8 page rendered. + // For Phase 9 we use the project name; for Phase 8 we already had it. + return ( +
+
+

+ Request to {project.name} +

+
+ +
+ ); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +- [ ] **Step 3: Commit** + +```bash +git add "src/app/intake/[token]/p/[projectId]/page.tsx" +git commit -m "feat(intake): wizard page at /intake/[token]/p/[projectId] + +Phase 9b Task 5. Verifies the URL's projectId belongs to the link's +client AND is opted in AND is active. Phase 8 URLs (with link.project +set, link.client empty) ignore the URL projectId and use the link's +own — so the Phase 8 wizard URL pattern still works." +``` + +--- + +## Task 6: Wire the wizard to send `projectId` and switch to the per-(link, project) cap + +**Files:** +- Modify: `src/components/intake/intake-public.tsx` (pass `projectId` to the action) +- Modify: `src/app/actions/intake-public.ts` (accept and forward `projectId`) +- Modify: `src/app/api/public/intake/route.ts` (accept `projectId` and verify it) + +This is the wire-up that connects the picker to Phase 9's per-(link, project) daily cap and to the wizard's `nextStep` call. + +- [ ] **Step 1: Update `` to thread `projectId`** + +`src/components/intake/intake-public.tsx`, in `callAction`: + +```tsx +async function callAction(nextTurns: Turn[], finishNow: boolean): Promise { + const { intakeStepPublicAction } = await import("@/app/actions/intake-public"); + return intakeStepPublicAction({ + token, + projectId, // NEW — passed through from the page + turns: nextTurns, + finishNow, + }); +} +``` + +The `projectId` prop is already part of ``'s signature from Phase 8 — the wizard page (Task 5) threads it through. + +- [ ] **Step 2: Update the Server Action** + +`src/app/actions/intake-public.ts`: + +```ts +export async function intakeStepPublicAction(input: { + token: string; + projectId: string; + turns: Turn[]; + finishNow: boolean; +}): Promise { + if (!isIntakeToken(input.token)) { + return { kind: "unavailable", message: "This link is no longer active." }; + } + + const requestHeaders = await headers(); + const ip = clientIpFromHeaders(requestHeaders); + // Key the rate limit on the (token, ip) pair — same as Phase 8. + if (!allow(`${input.token}:${ip}`)) { + return { kind: "unavailable", message: "Too many requests right now." }; + } + + const link = await resolveIntakeLink(input.token); + if (!link || !link.active) { + return { kind: "unavailable", message: "This link is no longer active." }; + } + if ( + link.expires_at && + link.expires_at < new Date().toISOString().slice(0, 10) + ) { + return { kind: "unavailable", message: "This link is no longer active." }; + } + + // Resolve the projectId from the link + the URL arg: + // - Phase 9 (link.client set): the URL's projectId must be a real + // project on the client and show_in_intake = true. + // - Phase 8 (link.project set): the URL's projectId is ignored. + // - Either way: the link must be usable for THIS project. + const resolvedProjectId = link.client ? input.projectId : link.project; + if (!resolvedProjectId) { + return { kind: "unavailable", message: "This link is no longer active." }; + } + if (link.client) { + const project = await resolveProjectForClient(link.client, resolvedProjectId); + if (!project) { + return { kind: "unavailable", message: "This link is no longer active." }; + } + // Phase 9 cap: per (link, project). + const usable = await isLinkProjectUsable(link, project.id); + if (!usable) { + const today = computeDateForCounter(); + const existing = await getAdminPb() + .collection("intake_link_submissions") + .getFirstListItem( + `link = "${link.id}" && project = "${project.id}" && date = "${today}"` + ) + .catch(() => null); + if (existing) { + return { + kind: "unavailable", + message: "You've reached the limit for today.", + }; + } + return { kind: "unavailable", message: "This link is no longer active." }; + } + } else { + // Phase 8 cap: per-link, via the link's own fields. + if (!isLinkUsable(link)) { + return { + kind: "unavailable", + message: "You've reached the limit for today.", + }; + } + } + + return nextStep({ + projectId: resolvedProjectId, + turns: input.turns, + finishNow: input.finishNow, + }); +} +``` + +This is a bigger change than I'd like — the action is doing both Phase 8 and Phase 9 cap checking. Let me consider extracting a `validateIntakeLinkForProject(link, projectId, now)` helper to keep the action small. + +Going with the helper approach. Add a sibling to `validateIntakeLinkForStep` (Phase 8's `src/lib/intake-links/index.ts`): + +```ts +/** + * Validate a token + projectId pair for a single step request. + * + * Returns ok=true with the resolved projectId when usable; ok=false + * with a reason otherwise. The reason feeds the same wording the + * Phase 8 wizard used. + * + * Used on the Phase 9 picker path. Phase 8 paths keep calling + * validateIntakeLinkForStep. + */ +export async function validateIntakeLinkForStepAtProject(input: { + token: string; + projectId: string; +}): Promise< + | { ok: true; link: IntakeLinkRecord; projectId: string } + | { + ok: false; + reason: "inactive" | "expired" | "project-mismatch" | "daily-cap"; + } +> { + const link = await resolveIntakeLink(input.token); + if (!link) return { ok: false, reason: "inactive" }; + if (!link.active) return { ok: false, reason: "inactive" }; + if ( + link.expires_at && + link.expires_at < new Date().toISOString().slice(0, 10) + ) { + return { ok: false, reason: "expired" }; + } + if (!link.client) { + // Phase 8 path: a Phase 9 client-bound picker page would never + // call this function with no client. But call defensively. + return { ok: false, reason: "inactive" }; + } + const project = await resolveProjectForClient(link.client, input.projectId); + if (!project) return { ok: false, reason: "project-mismatch" }; + + const usable = await isLinkProjectUsable(link, project.id); + if (!usable) return { ok: false, reason: "daily-cap" }; + + return { ok: true, link, projectId: project.id }; +} +``` + +Then `intakeStepPublicAction` becomes: + +```ts +export async function intakeStepPublicAction(input: { + token: string; + projectId: string; + turns: Turn[]; + finishNow: boolean; +}): Promise { + if (!isIntakeToken(input.token)) { + return { kind: "unavailable", message: "This link is no longer active." }; + } + const requestHeaders = await headers(); + const ip = clientIpFromHeaders(requestHeaders); + if (!allow(`${input.token}:${ip}`)) { + return { kind: "unavailable", message: "Too many requests right now." }; + } + + // Phase 8 path: link.project set, link.client empty — fall through. + const link = await resolveIntakeLink(input.token); + if (link && !link.client) { + const state = await validateIntakeLinkForStep(input.token); + if (!state.ok) { + if (state.reason === "daily-cap") { + return { kind: "unavailable", message: "You've reached the limit for today." }; + } + return { kind: "unavailable", message: "This link is no longer active." }; + } + return nextStep({ + projectId: state.projectId, + turns: input.turns, + finishNow: input.finishNow, + }); + } + + // Phase 9 path: client-bound. + const state = await validateIntakeLinkForStepAtProject({ + token: input.token, + projectId: input.projectId, + }); + if (!state.ok) { + if (state.reason === "daily-cap") { + return { kind: "unavailable", message: "You've reached the limit for today." }; + } + return { kind: "unavailable", message: "This link is no longer active." }; + } + + return nextStep({ + projectId: state.projectId, + turns: input.turns, + finishNow: input.finishNow, + }); +} +``` + +That's cleaner. + +- [ ] **Step 3: Update the public submission route** + +`src/app/api/public/intake/route.ts`, in the `POST` handler, after the token check: + +```ts + // Phase 9 cap: per (link, project) when the link is client-bound. + if (link.client) { + const projectId = record.projectId; + if (typeof projectId !== "string" || !projectId) { + return jsonError("Invalid request", 400); + } + const project = await resolveProjectForClient(link.client, projectId); + if (!project) { + return jsonError("Not found", 404); + } + const usable = await isLinkProjectUsable(link, project.id); + if (!usable) { + return jsonError("You've reached the limit for today.", 429); + } + } + + // Phase 8 path: cap check lives inline as before. (Same code.) + // (the existing per-link `isLinkUsable` check stays) +``` + +Move the existing `isLinkUsable(link)` check inside an `else` so Phase 9 doesn't double-check. Also bump the per-(link, project) counter after a successful Phase 9 submission by calling `bumpDailyCapForProject(link, project.id)`. + +- [ ] **Step 4: Verify the build** + +```bash +npm run build +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/components/intake/intake-public.tsx src/app/actions/intake-public.ts src/app/api/public/intake/route.ts src/lib/intake-links/index.ts +git commit -m "feat(intake): wire picker path through the action and route + +Phase 9b Task 6. intakeStepPublicAction takes projectId; resolves +the right cap (per-(link, project) for Phase 9, per-link for Phase 8). +POST /api/public/intake verifies the URL's projectId belongs to the +link's client and bumps intake_link_submissions on success. Phase 8 +URLs continue to work because the action falls back to the Phase 8 +path when link.client is empty." +``` + +--- + +## Task 7: The thanks page knows about projectId + +**Files:** +- Modify: `src/app/intake/[token]/sent/[cardId]/page.tsx` + +The "Send another request" link in the thanks page used to point back to `/intake/[token>]` (Phase 8: one project, so back to the wizard start). Phase 9 has the picker, so it should go back to `/intake/` (the picker), not to the wizard for one specific project. + +But: if the link is a Phase 8 per-project link, going back to `/intake/` should redirect straight to the wizard (Phase 8 behaviour). The existing code already does this — `/intake/[token]` page handles both cases. + +So: the thanks page link is just `` and the picker page does the right thing. **No code change needed.** Mark this task as confirming rather than editing. + +- [ ] **Step 1: Confirm** + +```bash +grep -n "Send another" "src/app/intake/[token]/sent/[cardId]/page.tsx" +``` + +Expected: a link to `/intake/${token}`. If so, no change. If not, update it to that path. + +- [ ] **Step 2: Commit (no-op if no change)** + +If nothing changed, `git commit --allow-empty -m "chore(intake): confirm thanks page links to picker"`. + +--- + +## Task 8: Full verification + +- [ ] **Step 1: Run every test** + +```bash +set -a && source .env.local && set +a && npm run test +``` + +Expected: 463 unit, all pass. + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +- [ ] **Step 3: Manual end-to-end** + +1. Sign in as the owner. Go to `/admin/clients`. Click any client name. +2. Click **New request link**, type a label, save. Copy the URL. +3. Open the URL in a private window. The picker shows the projects that have "List on request page" on. Click one. The wizard starts. +4. Run through 2 questions, **That's enough — write it up**, review the brief, **Send this**. +5. Land on the thanks page. Click **Send another request** — picker again. +6. Back at `/admin/clients/[id]`, click **Rotate** on the link. Old URL 404s; new URL works. +7. On `/admin/projects/[id]`, the new toggle is visible. Flip it on. Picker picks up the project. Flip off — picker hides it. + +Expected: every transition is reflected on the next page load. + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A +git commit -m "fix(phase-9b): issues found during Phase 9b verification" +``` + +--- + +## Definition of done + +1. `npm run test` passes: 463 unit. +2. `npm run build` succeeds; colour check empty (modulo PWA themeColor). +3. The picker flow works end-to-end: open `/intake/` in a + private window, pick a project, run the wizard, submit, card lands + with `created_by_role=anonymous` in the chosen project's inbox. +4. `/admin/clients/[id]` exists and shows request links + project + opt-in toggles. +5. `intakeStepPublicAction` accepts `projectId` and uses the + per-(link, project) cap on Phase 9 links. +6. Phase 8 URLs continue to work: `/intake/` for a per-project + link still renders the wizard directly. +7. The PWA service worker still caches `/intake/*` (no SW change + needed; the picker is part of the same cache allowlist). + +## Deliberately not in this plan + +- The integration tests for the picker path (9c). +- The `created_via` enum on `intake_links` (a 9.5 polish). +- Per-project submission count display in the owner UI (a 9.5 polish). +- Branch-by-tenant analytics (out of scope per the spec §12).