feat(intake): wire picker path through action and route

Phase 9b Task 6. intakeStepPublicAction takes optional projectId;
branches on link.client — Phase 9 path runs isLinkProjectUsable
(per-(link, project) cap) and resolveProjectForClient; Phase 8 path
falls through to validateIntakeLinkForStep unchanged. The wizard
component threads projectId from the URL into the action.

POST /api/public/intake verifies the URL's projectId belongs to the
link's client and is opted in; 404 if not, 429 if the
per-(link, project) cap is reached, 201 on success. On success the
bump goes to bumpDailyCapForProject (Phase 9) or the legacy
bumpSubmissionCount (Phase 8).

Phase 8 backwards compat: a per-project link (link.client empty)
goes through the unchanged Phase 8 path; body without projectId
still works on the Phase 8 route.
This commit is contained in:
Dave 2026-09-21 13:35:19 +02:00
parent b1d7abe6f9
commit f2477701cc
3 changed files with 114 additions and 9 deletions

View file

@ -4,9 +4,18 @@ import { headers } from "next/headers";
import { clientIpFromHeaders } from "@/lib/http/client-ip";
import { allow } from "@/lib/intake-links/rate-limit";
import { validateIntakeLinkForStep } from "@/lib/intake-links";
import {
isLinkProjectUsable,
resolveIntakeLink,
validateIntakeLinkForStep,
} from "@/lib/intake-links";
import { isIntakeToken } from "@/lib/intake/token";
import { nextStep, type InterviewStep } from "@/lib/intake/interview";
import {
resolveProjectForClient,
} from "@/lib/intake-links/client-projects";
import { getAdminPb } from "@/lib/pb/admin";
import { computeDateForCounter } from "@/lib/intake-links/submissions";
import type { Turn } from "@/lib/intake/turn";
/**
@ -27,9 +36,15 @@ import type { Turn } from "@/lib/intake/turn";
* as "daily cap reached" intentionally — the spec's §3 "doesn't shout"
* property — except when the cap is the actual cause, where we say so
* because the client might be able to come back tomorrow.
*
* Phase 9: the action branches on `link.client`. When set, the daily cap
* is per-(link, project) and the URL's projectId is verified against
* the link's client before nextStep runs. When unset (legacy Phase 8
* per-project link), behaviour is unchanged.
*/
export async function intakeStepPublicAction(input: {
token: string;
projectId?: string;
turns: Turn[];
finishNow: boolean;
}): Promise<InterviewStep> {
@ -43,6 +58,62 @@ export async function intakeStepPublicAction(input: {
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." };
}
if (link.client) {
// Phase 9 path: client-bound. The URL must carry a projectId; the
// project must belong to this client and be opted in / active.
const projectId = input.projectId;
if (!projectId) {
return { kind: "unavailable", message: "This link is no longer active." };
}
const project = await resolveProjectForClient(link.client, projectId);
if (!project) {
return { kind: "unavailable", message: "This link is no longer active." };
}
const usable = await isLinkProjectUsable(link, project.id);
if (!usable) {
// Distinguish "cap reached" only when we can prove it: a row for
// today exists and the count is at/over the cap. Otherwise treat
// as the generic "no longer active" to keep the wording consistent.
const pb = await getAdminPb();
const today = computeDateForCounter();
try {
const row = await pb
.collection("intake_link_submissions")
.getFirstListItem(
`link = "${link.id}" && project = "${project.id}" && date = "${today}"`
);
if (row && row.count >= (link.max_submissions_per_day || 100)) {
return {
kind: "unavailable",
message: "You've reached the limit for today.",
};
}
} catch {
// No row — fall through to the generic wording.
}
return { kind: "unavailable", message: "This link is no longer active." };
}
return nextStep({
projectId: project.id,
turns: input.turns,
finishNow: input.finishNow,
});
}
// Phase 8 path: per-project link.
const state = await validateIntakeLinkForStep(input.token);
if (!state.ok) {
if (state.reason === "daily-cap") {
@ -50,7 +121,6 @@ export async function intakeStepPublicAction(input: {
}
return { kind: "unavailable", message: "This link is no longer active." };
}
return nextStep({
projectId: state.projectId,
turns: input.turns,

View file

@ -5,9 +5,12 @@ import { parseBrief } from "@/lib/intake/brief";
import { sanitiseTurns } from "@/lib/intake/turn";
import { isIntakeToken } from "@/lib/intake/token";
import {
bumpDailyCapForProject,
bumpSubmissionCount,
isLinkProjectUsable,
resolveIntakeLink,
} from "@/lib/intake-links";
import { resolveProjectForClient } from "@/lib/intake-links/client-projects";
import { jsonError, jsonOk } from "@/lib/http/json";
import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
@ -27,6 +30,11 @@ import type { CardRecord, ColumnRecord } from "@/lib/pb/types";
*
* Token resolution happens here too, so a malformed body without a `token`
* fails before the database is touched.
*
* Phase 9: when the link is client-bound (link.client set), the body must
* carry a projectId, and that projectId must belong to the link's client
* AND be opted in / active. The daily cap is per-(link, project). When
* the link is per-project (Phase 8 shape), behaviour is unchanged.
*/
export async function POST(request: Request): Promise<Response> {
let body: unknown;
@ -56,6 +64,26 @@ export async function POST(request: Request): Promise<Response> {
const pb = await getAdminPb();
// Phase 9: resolve the projectId from the body, verify it against
// the link's client, and check the per-(link, project) cap.
let resolvedProjectId = link.project;
if (link.client) {
const projectId = record.projectId;
if (typeof projectId !== "string" || !projectId) {
return jsonError("Not found", 404);
}
const project = await resolveProjectForClient(link.client, projectId);
if (!project) {
return jsonError("Not found", 404);
}
resolvedProjectId = project.id;
const usable = await isLinkProjectUsable(link, project.id);
if (!usable) {
return jsonError("You've reached the limit for today.", 429);
}
}
// Re-parsed, not trusted. The browser could send anything here.
const turns = sanitiseTurns(record.transcript as never);
const brief = parseBrief(
@ -72,12 +100,12 @@ export async function POST(request: Request): Promise<Response> {
const inbox = await pb
.collection("columns")
.getFirstListItem<ColumnRecord>(
`project = "${link.project}" && is_inbox = true`
`project = "${resolvedProjectId}" && is_inbox = true`
)
.catch(() => null);
if (!inbox) {
console.error(`[intake-public] project ${link.project} has no inbox column`);
console.error(`[intake-public] project ${resolvedProjectId} has no inbox column`);
return jsonError("This project cannot accept new cards", 409);
}
@ -89,12 +117,12 @@ export async function POST(request: Request): Promise<Response> {
.catch(() => null);
const numbered = await pb.collection("cards").getFullList<CardRecord>({
filter: `project = "${link.project}"`,
filter: `project = "${resolvedProjectId}"`,
fields: "number",
});
const card = await pb.collection("cards").create<CardRecord>({
project: link.project,
project: resolvedProjectId,
// Every field below is chosen here, not taken from the request.
column: inbox.id,
title: brief.title,
@ -123,7 +151,7 @@ export async function POST(request: Request): Promise<Response> {
}
await pb.collection("activity").create({
project: link.project,
project: resolvedProjectId,
card: card.id,
// No actor — the submitter is anonymous by construction. The card detail
// page distinguishes anonymous submissions via the
@ -138,9 +166,15 @@ export async function POST(request: Request): Promise<Response> {
// The card is already created; an inconsistent counter is recoverable from
// the admin UI.
try {
await bumpSubmissionCount(link);
if (link.client) {
// Phase 9: per-(link, project) counter.
await bumpDailyCapForProject(link, resolvedProjectId);
} else {
// Phase 8: per-link counter (legacy).
await bumpSubmissionCount(link);
}
} catch (error) {
console.error("[intake-public] bumpSubmissionCount failed:", error);
console.error("[intake-public] bumpCounter failed:", error);
}
return jsonOk(

View file

@ -35,6 +35,7 @@ export function IntakePublic({
const { intakeStepPublicAction } = await import("@/app/actions/intake-public");
return intakeStepPublicAction({
token,
projectId,
turns: nextTurns,
finishNow,
});