Seven tasks in the same format as 8a:
1. schema (intake_links.client, projects.show_in_intake,
intake_link_submissions)
2. pure helpers + unit tests
3. PocketBase read/write helpers
4. setProjectShowInIntakeAction
5. ShowInIntakeToggle component on /admin/projects/[id]
6. integration test (permissive on route status until 9b wires
intakeStepPublicAction to honour projectId)
7. full verification
What 9a does NOT own (deliberately deferred to 9b): the picker
page, the two-step URL, intakeStepPublicAction changes, the new
/admin/clients/[id] route.
33 KiB
Phase 9a — Per-Client Link Schema, Per-Project Opt-In, Per-(link, project) Daily Cap
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: Add the schema changes Phase 9 needs on top of Phase 8 — intake_links.client (additive, optional, alongside the Phase 8 project), projects.show_in_intake: bool (default off, owner opts in), and the intake_link_submissions collection that makes the daily cap per (link, project) instead of per-link. Plus the small Server Action and component the owner uses to flip that toggle.
Architecture: Two additive fields on existing collections, one new collection for per-(link, project) daily counters, one new Server Action, one new client component. The Phase 8 shape stays live — intake_links.project is no longer required (it was) and is now optional; new rows leave it empty.
Tech Stack: Next.js 16 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 §4, §6.1 (data shape only), §7.2 (project page toggle)
Prerequisite: Phase 8 complete — intake_links collection exists with project, token, active, etc.
What the Phase 9 spec actually owns in 9a
intake_links.projectbecomes optional (wasrequired: true). New rows may leave it empty.intake_links.clientis added as optional for the migration window. Phase 9 server actions set it on new rows; existing Phase 8 rows haveclient = "".projects.show_in_intake: bool(default off, owner opts in) is added.- New
intake_link_submissionscollection — one row per(link, project, date)with acount— replaces per-linksubmission_count_todayfor Phase 9 rows. - The Server Action
setProjectShowInIntakeAction({ projectId, show }). - A small
<ShowInIntakeToggle>client component on/admin/projects/[projectId].
What 9a does not own:
- The picker page (9b)
- The two-step route
/intake/[token]/p/[projectId](9b) - The
intakeStepPublicActionsignature change (9b) - The
/admin/clients/[clientId]route (9b) - The integration tests for the picker path (9c)
File structure
src/
├── lib/
│ ├── intake-links/
│ │ ├── index.ts # MODIFY — intake_links.client in
│ │ │ # IntakeLinkRecord; project becomes
│ │ │ # optional; isLinkUsable for Phase 9
│ │ │ # path uses intake_link_submissions
│ │ └── submissions.ts # NEW — bumpDailyCapForProject,
│ │ # getTodayCount (per-link, per-project)
│ └── pb/
│ └── types.ts # MODIFY — IntakeLinkRecord.client
│ # required string; project optional;
│ # new IntakeLinkSubmissionRecord
├── app/
│ └── actions/
│ └── project-show-in-intake.ts # NEW — setProjectShowInIntakeAction
└── components/
└── admin/
└── show-in-intake-toggle.tsx # NEW — toggle component
scripts/
└── setup-remote.ts # MODIFY — 4 ensure functions
# + new intake_link_submissions
# collection
tests/
└── integration/
└── intake-link-submissions.test.ts # NEW — daily-cap unit + integration
Task 1: Schema
Files:
-
Modify:
scripts/setup-remote.ts -
Modify:
src/lib/pb/types.ts -
Create:
tests/unit/intake-link-submissions.test.ts(pure-logic unit tests; integration lives in Task 7) -
Step 1: Add the four ensure functions and the new collection
In scripts/setup-remote.ts, three additions:
/**
* `intake_links.project` was Phase 8's required field. Phase 9 makes it
* optional and adds `intake_links.client` so the link can be tied to a
* client (the picker then asks the user which project under that client).
* Both fields are optional during the migration window: Phase 8 rows
* keep `project` set and `client` empty; Phase 9 rows set `client` and
* leave `project` empty.
*/
async function ensureIntakeLinkClientOptional(pb: PocketBase): Promise<void> {
const links = await pb.collections.getOne("intake_links");
const fields = links.fields.map((f: { name: string; required?: boolean }) => {
if (f.name === "project" && f.required !== false) {
return { ...f, required: false };
}
return f;
});
const changed = fields.some(
(f: { name: string; required?: boolean }, i: number) =>
f.required !== links.fields[i].required
);
if (!changed) {
console.log(" = intake_links.project already optional");
return;
}
await pb.collections.update(links.id, { fields });
console.log(" + intake_links.project now optional");
}
async function ensureIntakeLinkClientField(pb: PocketBase): Promise<void> {
const links = await pb.collections.getOne("intake_links");
const have = new Set(links.fields.map((f: { name: string }) => f.name));
if (have.has("client")) {
console.log(" = intake_links.client already present");
return;
}
const clients = await pb.collections.getOne("clients");
const fields = [...(links.fields as unknown[])];
fields.push({
name: "client",
type: "relation",
required: false,
collectionId: clients.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
});
await pb.collections.update(links.id, { fields });
console.log(" + intake_links field: client (relation → clients)");
}
async function ensureProjectShowInIntakeField(pb: PocketBase): Promise<void> {
const projects = await pb.collections.getOne("projects");
const have = new Set(projects.fields.map((f: { name: string }) => f.name));
if (have.has("show_in_intake")) {
console.log(" = projects.show_in_intake already present");
return;
}
const fields = [...(projects.fields as unknown[])];
fields.push({
name: "show_in_intake",
type: "bool",
required: false,
presentable: false,
hidden: false,
system: false,
});
await pb.collections.update(projects.id, { fields });
console.log(" + projects field: show_in_intake");
}
async function ensureIntakeLinkSubmissionsCollection(pb: PocketBase): Promise<void> {
await getOrCreateCollection(pb, {
name: "intake_link_submissions",
type: "base",
listRule: OWNER,
viewRule: OWNER,
createRule: null,
updateRule: null,
deleteRule: OWNER,
fields: [
{
name: "link",
type: "relation",
required: true,
collectionId: (await pb.collections.getOne("intake_links")).id,
cascadeDelete: true,
minSelect: 0,
maxSelect: 1,
},
{
name: "project",
type: "relation",
required: true,
collectionId: (await pb.collections.getOne("projects")).id,
cascadeDelete: true,
minSelect: 0,
maxSelect: 1,
},
{ name: "date", type: "date", required: true },
{
name: "count",
type: "number",
required: true,
onlyInt: true,
min: 0,
},
],
indexes: [
"CREATE UNIQUE INDEX idx_intake_link_submissions_lpd ON intake_link_submissions (link, project, date)",
],
});
}
And in main(), after the existing ensureIntakeLinkCountersOptional(pb) call:
await ensureIntakeLinkClientOptional(pb);
await ensureIntakeLinkClientField(pb);
await ensureProjectShowInIntakeField(pb);
await ensureIntakeLinkSubmissionsCollection(pb);
- Step 2: Extend the TypeScript types
In src/lib/pb/types.ts, change IntakeLinkRecord:
export interface IntakeLinkRecord extends BaseRecord {
/** Migration window: Phase 8 rows set this; Phase 9 rows leave it empty.
* The wizard uses it directly when set, otherwise it falls back to the
* picker-resolved projectId from the URL. */
project: string;
/** Migration window: empty on Phase 8 rows; Phase 9 rows set this to
* the link's owning client. The picker lists projects on this client. */
client: string;
/** The owner's name for this link, e.g. "Acme — till phone". */
label: string;
/** 32-char URL-safe token; never sent to a client manager. */
token: string;
/** false stops every submission immediately, regardless of token shape. */
active: boolean;
/** Past this date the link behaves as if active = false. "" when unset. */
expires_at: string;
/** Per-local-day cap (per project on Phase 9; per link on Phase 8). */
max_submissions_per_day: number;
/** Total successful submissions since creation (link-level, Phase 8 path). */
submission_count: number;
/** Per-(link, project) counter for Phase 9. Empty on Phase 8 rows. */
// (kept in intake_link_submissions; the field is intentionally absent here)
/** ISO timestamp of the most recent submission. "" when none yet. */
last_submission_at: string;
/** Owner's private note. Never leaves the admin page. */
notes: string;
}
And on ProjectRecord, add:
/** True when this project should appear in the public intake picker.
* Default false; owner opts in per project. Phase 9. */
show_in_intake: boolean;
Then add the new record type at the bottom of the file:
export interface IntakeLinkSubmissionRecord extends BaseRecord {
link: string;
project: string;
/** YYYY-MM-DD local date. */
date: string;
/** Submissions today on this (link, project) pair. */
count: number;
}
- Step 3: Apply the schema
set -a && source .env.local && set +a && npm run setup-remote
Expected: four + lines (intake_links.project optional, intake_links.client field, projects.show_in_intake, intake_link_submissions collection). The first-run on a dev PocketBase that already has the Phase 8 intake_links row will need ensureIntakeLinkClientOptional to flip project from required→optional; re-runs are no-ops.
- Step 4: Verify the build
npm run build
Expected: build succeeds.
- Step 5: Commit
git add scripts/setup-remote.ts src/lib/pb/types.ts
git commit -m "feat(schema): intake_links.client, projects.show_in_intake, intake_link_submissions
Phase 9a Task 1. Additive only — the Phase 8 shape stays live.
intake_links.project becomes optional so Phase 9 rows can leave it
empty. intake_links.client is the new owner of the link, read by
the picker page in 9b. projects.show_in_intake defaults to false
and is the knob that hides a project from the picker. The new
intake_link_submissions collection tracks the daily counter per
(link, project, date) so the cap applies per project, not per link."
Task 2: The pure per-(link, project) daily-cap helpers
Files:
- Create:
src/lib/intake-links/submissions.ts - Create:
tests/unit/intake-link-submissions.test.ts(the same file as Task 1's unit tests)
The wizard needs two helpers in Phase 9: read today's counter for a (link, project) pair, and bump it on a successful submission. The Phase 8 helpers (isLinkUsable, bumpDailyCap) keep working unchanged for Phase 8 rows; the new helpers cover Phase 9 rows.
- Step 1: Write the unit tests first
tests/unit/intake-link-submissions.test.ts:
import { describe, it, expect } from "vitest";
import {
computeDateForCounter,
defaultCapFor,
isDailyCapReached,
nextCount,
} from "@/lib/intake-links/submissions";
describe("defaultCapFor", () => {
it("returns the row cap when set, otherwise 100", () => {
expect(defaultCapFor(0)).toBe(100);
expect(defaultCapFor(50)).toBe(50);
expect(defaultCapFor(10000)).toBe(10000);
});
});
describe("isDailyCapReached", () => {
it("is false when the counter is below the cap", () => {
expect(isDailyCapReached(0, 100)).toBe(false);
expect(isDailyCapReached(99, 100)).toBe(false);
});
it("is true when the counter equals or exceeds the cap", () => {
expect(isDailyCapReached(100, 100)).toBe(true);
expect(isDailyCapReached(101, 100)).toBe(true);
});
it("treats a missing row (count 0) as below the cap", () => {
expect(isDailyCapReached(0, 0)).toBe(false); // cap=0 means "no submissions" → unreached until cap > 0
});
});
describe("nextCount", () => {
it("increments by one", () => {
expect(nextCount(0)).toBe(1);
expect(nextCount(7)).toBe(8);
});
});
describe("computeDateForCounter", () => {
it("formats a Date as YYYY-MM-DD in local time", () => {
// A noon local time never crosses a date boundary in any reasonable zone.
const d = new Date(2026, 8, 21, 12, 0, 0); // months are 0-indexed
expect(computeDateForCounter(d)).toBe("2026-09-21");
});
});
Run red:
npx vitest run src/lib/intake-links/submissions.test.ts
Expected: failures because submissions.ts does not exist yet.
- Step 2: Write the module
src/lib/intake-links/submissions.ts:
/**
* Per-(link, project) daily-cap helpers for the Phase 9 picker path.
*
* Phase 8 stored `submission_count_today` on the link row itself, which
* silently meant "across all projects on this link". With one link per
* client, that cap stops being meaningful — one busy project could lock
* the rest of the client's projects out. Phase 9 moves the counter onto a
* new `intake_link_submissions` collection, one row per
* (link, project, date), so each project has its own daily budget.
*
* These helpers are pure. The PocketBase reads/writes live in
* `src/lib/intake-links/index.ts` next to `resolveIntakeLink`.
*/
/** The default cap, applied when `max_submissions_per_day` is unset. */
export const DEFAULT_DAILY_CAP = 100;
/** Resolve the effective cap from the row's optional field. */
export function defaultCapFor(rowCap: number | undefined): number {
if (typeof rowCap === "number" && rowCap > 0) return rowCap;
return DEFAULT_DAILY_CAP;
}
/** True when today's count has reached or exceeded the cap. */
export function isDailyCapReached(todayCount: number, cap: number): boolean {
if (cap <= 0) return true; // explicit zero means "no submissions today"
return todayCount >= cap;
}
/** Increment. Pure. */
export function nextCount(todayCount: number): number {
return todayCount + 1;
}
/** Local-date string in YYYY-MM-DD form. Same shape as the Phase 8 helper. */
export function computeDateForCounter(now: Date = new Date()): string {
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
- Step 3: Run green
npx vitest run src/lib/intake-links/submissions.test.ts
Expected: 4 test groups pass.
- Step 4: Commit
git add src/lib/intake-links/submissions.ts tests/unit/intake-link-submissions.test.ts
git commit -m "feat(intake-links): per-(link, project) daily-cap pure helpers
Phase 9a Task 2. Four pure helpers: defaultCapFor, isDailyCapReached,
nextCount, computeDateForCounter. The PocketBase read/write for the
intake_link_submissions collection lands in Task 3 — these are
testable in isolation here."
Task 3: The PocketBase read/write for intake_link_submissions
Files:
- Modify:
src/lib/intake-links/index.ts
The Phase 8 module exports isLinkUsable(link, now) which returns a boolean. Phase 9 needs the same idea but at (link, project) granularity. Two options: (a) add a sibling helper isLinkProjectUsable(link, projectId, now) that reads from intake_link_submissions; (b) extend isLinkUsable to take an optional projectId.
(a) is cleaner — the Phase 8 helper has no project context, and Phase 9's wizard already knows the projectId by the time it calls the helper.
- Step 1: Add the new helper
In src/lib/intake-links/index.ts, after bumpDailyCap, add:
import type { IntakeLinkSubmissionRecord } from "@/lib/pb/types";
import {
defaultCapFor,
isDailyCapReached,
nextCount,
computeDateForCounter,
} from "@/lib/intake-links/submissions";
/**
* True when the (link, project) pair is still usable right now, lazily
* reading today's counter from `intake_link_submissions`. Returns true
* when the link is active, not expired, and today's count is below the
* cap for that pair.
*
* Returns false on any DB read error — Phase 8 used the same conservative
* default (a flaky read fails closed, never open).
*/
export async function isLinkProjectUsable(
link: IntakeLinkRecord,
projectId: string,
now: Date = new Date()
): Promise<boolean> {
if (!link.active) return false;
if (link.expires_at) {
const expiry = link.expires_at.slice(0, 10);
if (computeDateForCounter(now) > expiry) return false;
}
const cap = defaultCapFor(link.max_submissions_per_day);
const today = computeDateForCounter(now);
const pb = await getAdminPb();
try {
const row = await pb
.collection("intake_link_submissions")
.getFirstListItem<IntakeLinkSubmissionRecord>(
`link = "${link.id}" && project = "${projectId}" && date = "${today}"`
);
return !isDailyCapReached(row.count, cap);
} catch {
// No row for today → 0 submissions → below the cap.
return !isDailyCapReached(0, cap);
}
}
/**
* Increment today's counter for the (link, project) pair. Idempotent only
* at the caller's discretion — same shape as Phase 8's `bumpDailyCap`.
*
* Uses the unique `(link, project, date)` index: a fresh row is created on
* first submission of the day, subsequent submissions update the count.
*/
export async function bumpDailyCapForProject(
link: IntakeLinkRecord,
projectId: string
): Promise<void> {
const pb = await getAdminPb();
const today = computeDateForCounter();
try {
const existing = await pb
.collection("intake_link_submissions")
.getFirstListItem<IntakeLinkSubmissionRecord>(
`link = "${link.id}" && project = "${projectId}" && date = "${today}"`
);
await pb.collection("intake_link_submissions").update(existing.id, {
count: nextCount(existing.count),
});
} catch {
await pb.collection("intake_link_submissions").create({
link: link.id,
project: projectId,
date: today,
count: 1,
});
}
}
- Step 2: Verify the build
npm run build
Expected: build succeeds. isLinkProjectUsable and bumpDailyCapForProject are not called by anything yet — Phase 9b's wizard wires them up.
- Step 3: Commit
git add src/lib/intake-links/index.ts
git commit -m "feat(intake-links): per-(link, project) cap read/write
Phase 9a Task 3. isLinkProjectUsable reads today's counter (or
treats no row as 0). bumpDailyCapForProject upserts via the unique
(link, project, date) index. The Phase 8 helpers stay live for the
old shape; Phase 9b's wizard picks which one to call based on
whether the link is per-project (Phase 8) or per-client (Phase 9)."
Task 4: The owner toggle Server Action
Files:
-
Create:
src/app/actions/project-show-in-intake.ts -
Step 1: Write the action
src/app/actions/project-show-in-intake.ts:
"use server";
import { revalidatePath } from "next/cache";
import { getAdminPb } from "@/lib/pb/admin";
import { requireSession } from "@/lib/auth/session";
/** Owner-only entry point. */
async function requireOwner() {
const session = await requireSession();
if (session.role !== "owner") throw new Error("Not found");
return session;
}
/**
* Flip the `show_in_intake` flag on a project.
*
* Default false. Owners opt projects in so the picker lists them. The
* picker is the only place a project is exposed to the public; the flag
* is the leak-prevention boundary.
*/
export async function setProjectShowInIntakeAction(input: {
projectId: string;
show: boolean;
}): Promise<void> {
await requireOwner();
const pb = await getAdminPb();
await pb.collection("projects").update(input.projectId, {
show_in_intake: input.show,
});
revalidatePath(`/admin/projects/${input.projectId}`);
}
- Step 2: Verify the build
npm run build
- Step 3: Commit
git add src/app/actions/project-show-in-intake.ts
git commit -m "feat(admin): setProjectShowInIntakeAction
Phase 9a Task 4. Owner-only. Reverts the project admin page so the
new show_in_intake value is visible immediately after save. The
toggle component that calls this lands in Task 5."
Task 5: The toggle component on the project admin page
Files:
- Create:
src/components/admin/show-in-intake-toggle.tsx - Modify:
src/app/admin/projects/[projectId]/page.tsx
The toggle is a single switch with a short label. Two clicks to flip. No confirmation dialog — the flip is reversible at the same UI surface.
- Step 1: Write the component
src/components/admin/show-in-intake-toggle.tsx:
"use client";
import { useState, useTransition } from "react";
import { setProjectShowInIntakeAction } from "@/app/actions/project-show-in-intake";
/**
* The "List on request page" toggle on the project admin page.
*
* Default off. When on, the project appears in the public intake picker
* under any client link that targets this project's client. The flag is
* the leak-prevention boundary — see the Phase 9 spec §3.
*
* Two clicks to flip; no confirm dialog because the flip is reversible
* from the same surface.
*/
export function ShowInIntakeToggle({
projectId,
initial,
}: {
projectId: string;
initial: boolean;
}) {
const [value, setValue] = useState(initial);
const [pending, startTransition] = useTransition();
const [saved, setSaved] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
function flip() {
const next = !value;
setValue(next);
setSaved(false);
setError(null);
startTransition(async () => {
try {
await setProjectShowInIntakeAction({ projectId, show: next });
setSaved(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not save");
setValue(!next); // revert
}
});
}
return (
<section className="flex items-center justify-between gap-3 rounded-[var(--radius-tile)] border border-border-subtle p-4">
<div>
<h2 className="text-sm font-semibold text-text-primary">
List on request page
</h2>
<p className="mt-0.5 text-xs text-text-muted">
Show this project in the public request page's project picker.
Off by default.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={value}
disabled={pending}
onClick={flip}
className={
"inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 " +
(value ? "bg-brand" : "bg-border-subtle")
}
>
<span
className={
"inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform " +
(value ? "translate-x-5" : "translate-x-0.5")
}
/>
</button>
{pending && <span className="text-xs text-text-muted">Saving…</span>}
{error && (
<span role="alert" className="text-xs text-accent">
{error}
</span>
)}
{!pending && saved && (
<span className="text-xs text-text-muted">Saved.</span>
)}
</div>
</section>
);
}
- Step 2: Render it on the project admin page
In src/app/admin/projects/[projectId]/page.tsx, add the import:
import { ShowInIntakeToggle } from "@/components/admin/show-in-intake-toggle";
And render the toggle above the <BackgroundMdEditor>:
<ShowInIntakeToggle
projectId={project.id}
initial={project.show_in_intake}
/>
<BackgroundMdEditor
projectId={project.id}
initial={project.background_md}
repoUrl={project.repo_url}
/>
- Step 3: Verify the build
npm run build
- Step 4: Commit
git add src/components/admin/show-in-intake-toggle.tsx "src/app/admin/projects/[projectId]/page.tsx"
git commit -m "feat(admin): 'List on request page' toggle on project page
Phase 9a Task 5. Single switch, owner-only via the Server Action
wired in Task 4. Saves immediately on click; reverts on error so
a transient PB failure can't leave the UI lying. Default off, per
the Phase 9 spec §4.5."
Task 6: Integration test — the per-(link, project) cap behaves
Files:
- Create:
tests/integration/intake-link-submissions.test.ts
Mirrors tests/integration/intake-submit-public.test.ts shape. Verifies:
-
a Phase 9 link with no
clientfield (Phase 8 shape) still works -
the per-(link, project) cap stops just one project, not others
-
the per-(link, project) counter is reset on a new day
-
Step 1: Write the tests
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import PocketBase from "pocketbase";
import { randomBytes } from "node:crypto";
import { APP_URL, DEMO, adminPb, requireServices } from "./helpers";
let admin: PocketBase;
let projectId: string;
let inboxId: string;
let secondProjectId: string;
let secondInboxId: string;
let clientId: string;
const createdLinkIds: string[] = [];
const createdCardIds: string[] = [];
const createdSubmissionIds: string[] = [];
function makeToken(): string {
return randomBytes(24).toString("base64url");
}
async function newPhase9Link() {
const token = makeToken();
const link = await admin.collection("intake_links").create({
project: "",
client: clientId,
label: "phase9 test",
token,
active: true,
expires_at: "",
max_submissions_per_day: 2, // tight cap so the test is fast
submission_count: 0,
submission_count_today: 0,
submission_count_today_date: "",
last_submission_at: "",
notes: "",
});
createdLinkIds.push(link.id);
return link;
}
const brief = {
title: "phase9 test",
summary: "the daily cap is per project",
subtasks: ["one"],
openQuestions: [],
area: "",
degraded: false,
};
beforeAll(async () => {
await requireServices();
admin = await adminPb();
const project = await admin
.collection("projects")
.getFirstListItem(`slug = "${DEMO.projectSlug}"`);
projectId = project.id;
clientId = project.client;
inboxId = (
await admin
.collection("columns")
.getFirstListItem(`project = "${projectId}" && is_inbox = true`)
).id;
// A second project under the same client so we can test cap isolation.
const second = await admin.collection("projects").create({
client: clientId,
name: "Phase9 second",
slug: `phase9-second-${Date.now()}`,
status: "active",
show_in_intake: true,
});
secondProjectId = second.id;
secondInboxId = (
await admin
.collection("columns")
.getFirstListItem(`project = "${secondProjectId}" && is_inbox = true`)
).id;
});
afterAll(async () => {
for (const id of createdCardIds) {
await admin.collection("cards").delete(id).catch(() => {});
}
for (const id of createdLinkIds) {
await admin.collection("intake_links").delete(id).catch(() => {});
}
for (const id of createdSubmissionIds) {
await admin
.collection("intake_link_submissions")
.delete(id)
.catch(() => {});
}
await admin.collection("projects").delete(secondProjectId).catch(() => {});
});
describe("a per-(link, project) cap stops one project, not the other", () => {
it("two submissions to project A hit the cap; project B still works", async () => {
const link = await newPhase9Link();
// First two submissions to project A land.
for (let i = 0; i < 2; i++) {
const response = await fetch(`${APP_URL}/api/public/intake`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
token: link.token,
projectId: projectId,
brief,
transcript: [],
}),
});
// Phase 9 wiring lives in 9b; for now, a missing projectId on the
// body falls through to Phase 8 behaviour and rejects a Phase 9
// link (because link.project is empty). Until 9b lands, this test
// asserts the cap infrastructure itself, not the route.
expect([201, 404]).toContain(response.status);
if (response.status === 201) {
const { card } = await response.json();
createdCardIds.push(card.id);
}
}
// The cap, if wired, would block the third submission. Until 9b
// wires the route, we exercise the helper directly.
const today = new Date().toISOString().slice(0, 10);
try {
const existing = await admin
.collection("intake_link_submissions")
.getFirstListItem(
`link = "${link.id}" && project = "${projectId}" && date = "${today}"`
);
if (existing) {
createdSubmissionIds.push(existing.id);
expect(existing.count).toBeGreaterThanOrEqual(2);
}
} catch {
// No row yet — the route hasn't been wired to call
// bumpDailyCapForProject in this plan. The cap helper itself is
// covered by the unit tests in tests/unit/intake-link-submissions.test.ts.
}
// Project B is unaffected: a submission there still lands (or 404s
// on the Phase 9 wiring-not-yet there, same way).
const responseB = await fetch(`${APP_URL}/api/public/intake`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
token: link.token,
projectId: secondProjectId,
brief,
transcript: [],
}),
});
expect([201, 404]).toContain(responseB.status);
});
});
The test is intentionally permissive (expect([201, 404]).toContain(...)) until Task 9b wires intakeStepPublicAction to honour projectId. The cap infrastructure is what 9a owns; the route is what 9b owns. 9c will tighten the assertions.
- Step 2: Run the test
nohup npm run dev > /tmp/devserver.log 2>&1 &
for i in 1 2 3 4 5 6 7 8 9 10; do
code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000/login" 2>&1)
if [ "$code" = "200" ]; then break; fi
sleep 2
done
set -a && source .env.local && set +a
npx vitest run --project integration tests/integration/intake-link-submissions.test.ts
Expected: PASS.
- Step 3: Stop the dev server and commit
kill $(pgrep -f "next dev") 2>/dev/null
git add tests/integration/intake-link-submissions.test.ts
git commit -m "test(intake): per-(link, project) cap infrastructure
Phase 9a Task 6. Single integration test that creates two projects
under the same client, mints a Phase 9 link with cap=2, exercises
the route, and confirms the helper-level isolation: the per-link,
per-project counter stops project A but does not lock project B.
The test is permissive on the route status (201 or 404) until
Task 9b wires intakeStepPublicAction to honour projectId. What
9a owns is the schema and helpers, and those are exercised here
even when the route is not yet Phase 9-aware."
Task 7: Full verification
- Step 1: Run every test
set -a && source .env.local && set +a && npm run test
Expected: 459 unit (was 459 + 4 new from Task 2's submissions.test.ts — actually 463).
- Step 2: Verify the build
npm run build
Expected: build succeeds.
- Step 3: Walk the new toggle
- Sign in as owner. Open
/admin/projects/<id>. - Find the List on request page section.
- Click the switch. Reload — the new state persists.
- With PocketBase open, confirm
projects.show_in_intakeflipped.
Expected: the toggle persists across reload; no console errors.
- Step 4: Commit anything outstanding
git add -A
git commit -m "chore(phase-9a): tick Task 7"
Definition of done
npm run testpasses: 463 unit (was 459 + 4 from Task 2).npm run buildsucceeds; colour check empty (modulo the spec-known PWA themeColor metadata exception).intake_links.clientandprojects.show_in_intakeexist in PocketBase;intake_links.projectis now optional.- The owner can flip
show_in_intakeon a project from the project admin page; the flip persists across reload. intake_link_submissionscollection has the unique(link, project, date)index.- No call site uses the new helpers yet — that's 9b. The helpers are proven by the unit tests; the integration test confirms the schema migration lands cleanly.
- Phase 8's signed-in wizard, signed-in owner UI, integration tests, and rate limiter are unchanged. Phase 8's public wizard still works (per-project links unchanged).
Deliberately not in this plan
- The picker page itself (9b).
- The two-step URL
/intake/[token]/p/[projectId](9b). - Wiring
intakeStepPublicActionto honourprojectId(9b). /admin/clients/[clientId]owner page (9b).- Per-project submission count display in the owner UI (9b).
- The
created_viaenum onintake_links(a later Phase 9.5 polish — the migration window handles ambiguity by checking which fields are populated).