diff --git a/src/app/actions/intake-public.ts b/src/app/actions/intake-public.ts index 791d2b3..444eef3 100644 --- a/src/app/actions/intake-public.ts +++ b/src/app/actions/intake-public.ts @@ -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 { @@ -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, diff --git a/src/app/api/public/intake/route.ts b/src/app/api/public/intake/route.ts index f649d9c..c524b4e 100644 --- a/src/app/api/public/intake/route.ts +++ b/src/app/api/public/intake/route.ts @@ -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 { let body: unknown; @@ -56,6 +64,26 @@ export async function POST(request: Request): Promise { 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 { const inbox = await pb .collection("columns") .getFirstListItem( - `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 { .catch(() => null); const numbered = await pb.collection("cards").getFullList({ - filter: `project = "${link.project}"`, + filter: `project = "${resolvedProjectId}"`, fields: "number", }); const card = await pb.collection("cards").create({ - 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 { } 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 { // 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( diff --git a/src/components/intake/intake-public.tsx b/src/components/intake/intake-public.tsx index b02b78f..5d8b03d 100644 --- a/src/components/intake/intake-public.tsx +++ b/src/components/intake/intake-public.tsx @@ -35,6 +35,7 @@ export function IntakePublic({ const { intakeStepPublicAction } = await import("@/app/actions/intake-public"); return intakeStepPublicAction({ token, + projectId, turns: nextTurns, finishNow, });