Files
WebForm-mw/app/api/submit/route.ts
Joel Brock b4e80517a7 Derive current stage from most-recent stage-bearing activity
Stage authority moves from Organization.Food_Co_op_Organizing.Stage to the
most recent Check-in (organizing) activity whose Stage custom field is set.
Staff create these activities manually to mark transitions; org-owner form
submissions no longer write the Stage field at all, so they cannot override
a staff-set transition.

- /api/data: removed the Contact.get for org-side Stage; added an
  Activity.get filtered to ACTIVITY_TYPE_NAME + ACTIVITY_STAGE_FIELD IS
  NOT EMPTY, ordered by activity_date_time DESC, id DESC, limit 1.
  Fallback when no such activity exists: Inquiry (rank 0). Org-name
  lookup, stage activity, prefill, and option-group fetch all run in
  parallel via Promise.all.
- /api/submit: removed the stageAtSubmission read + the
  [ACTIVITY_STAGE_FIELD] write on the activity record. The form's
  activities are stage-null by design now.
- config/form.ts: dropped the stage_at_submission readonly field (no
  longer being set or displayed). Kept ACTIVITY_STAGE_FIELD export — it's
  now used by /api/data to find stage-bearing activities. Updated the
  current_stage field comment to reflect the new source.
- components/EngagementForm.tsx: dropped stage_at_submission from
  evalState (no longer referenced by any visibility rule or readonly
  display).

Org.Food_Co_op_Organizing.Stage remains in CiviCRM for staff list views;
the middleware no longer reads or writes it. No backfill required —
orgs without a stage-bearing activity simply read as Inquiry.
2026-05-13 11:41:25 -07:00

126 lines
4.1 KiB
TypeScript

/**
* POST /api/submit
*
* Body: SubmitPayload { cid, cs, values }.
*
* Verifies checksum, resolves org from cid (mirror of /api/data), then
* creates a new `Check-in (organizing)` activity. The activity's own Stage
* field is intentionally left null: the form is not the authority on stage.
* Staff set Stage on their own check-in activities to mark transitions, and
* /api/data derives the org's current stage from the most recent stage-
* bearing activity.
*
* STUB MODE: if CiviCRM env vars are unset, returns success without writing.
*/
import { NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm";
import { allFields, ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import type { SubmitPayload } from "@/types/form";
import { appEnv, redact } from "@/lib/env";
import { rateLimit, clientIp } from "@/lib/rate-limit";
function isStubMode(): boolean {
return !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
);
}
const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f]));
export async function POST(req: Request) {
// Rate limit per client IP. Generous default — 10 submissions per minute.
const ip = clientIp(req);
const rl = rateLimit(`submit:${ip}`, { capacity: 10, windowMs: 60_000 });
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many submissions. Please wait a moment and try again." },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(rl.resetMs / 1000)),
},
},
);
}
let body: SubmitPayload;
try {
body = (await req.json()) as SubmitPayload;
} catch {
return NextResponse.json({ error: "Malformed JSON body." }, { status: 400 });
}
const { cid, cs, values } = body;
if (!cid || !cs) {
return NextResponse.json({ error: "Missing cid or cs." }, { status: 400 });
}
if (isStubMode()) {
console.warn("[submit:STUB] would create Check-in (organizing) activity with values:", values);
return NextResponse.json({ ok: true, stub: true });
}
try {
return await runSubmit(cid, cs, values);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[submit] failure:", redact(msg));
return NextResponse.json(
{
error: appEnv().isProduction
? "Could not save your check-in. Please try again, or contact your engagement coordinator."
: `Save failed: ${redact(msg)}`,
},
{ status: 500 },
);
}
}
async function runSubmit(cid: string, cs: string, values: Record<string, unknown>): Promise<NextResponse> {
const ok = await verifyChecksum(cid, cs);
if (!ok) {
return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 });
}
// Resolve org via the relationship (mirror of /api/data).
const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", {
select: ["contact_id_b"],
where: [
["contact_id_a", "=", Number(cid)],
["relationship_type_id.name_a_b", "=", FORM_CONTACT_RELATIONSHIP],
["is_active", "=", true],
],
limit: 2,
});
const orgs = relRes.values ?? [];
if (orgs.length !== 1) {
return NextResponse.json(
{ error: "Could not resolve a unique organization for your contact." },
{ status: orgs.length === 0 ? 404 : 409 },
);
}
const orgId = orgs[0].contact_id_b;
// Build the activity record. The Stage custom field is deliberately NOT
// set here — staff own stage transitions on their own activities.
const activityRecord: Record<string, unknown> = {
"activity_type_id:name": ACTIVITY_TYPE_NAME,
"status_id:name": "Completed",
target_contact_id: orgId,
source_contact_id: Number(cid),
subject: "Co-op Check-in (form submission)",
};
for (const [name, value] of Object.entries(values)) {
const field = FIELD_BY_NAME.get(name);
if (!field || !field.civiField) continue;
if (field.type === "readonly") continue; // never write read-only fields
activityRecord[field.civiField] = value;
}
await civi("Activity", "create", { values: activityRecord });
return NextResponse.json({ ok: true });
}