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:
parent
b1d7abe6f9
commit
f2477701cc
3 changed files with 114 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export function IntakePublic({
|
|||
const { intakeStepPublicAction } = await import("@/app/actions/intake-public");
|
||||
return intakeStepPublicAction({
|
||||
token,
|
||||
projectId,
|
||||
turns: nextTurns,
|
||||
finishNow,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue