/** * 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 (`Check_in_data__organizing_.Stage`) is auto-populated from the * org's current `Food_Co_op_Organizing.Stage` so each check-in carries a * snapshot of where the org was at submission time. * * 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, ACTIVITY_STAGE_FIELD, 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): Promise { 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; // Read the org's current Framework Stage so we can snapshot it on the activity. const orgRes = await civi<{ "Food_Co_op_Organizing.Stage": string | null }>( "Contact", "get", { select: ["Food_Co_op_Organizing.Stage"], where: [["id", "=", orgId]], }, ); const stageAtSubmission = orgRes.values?.[0]?.["Food_Co_op_Organizing.Stage"] ?? null; // Build the activity record. Each form-side field name maps to its // configured `civiField` for the activity-side write. const activityRecord: Record = { "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)", [ACTIVITY_STAGE_FIELD]: stageAtSubmission, }; 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 if (name === "current_stage") continue; // org-side, never written to activity if (name === "stage_at_submission") continue; // handled above via ACTIVITY_STAGE_FIELD activityRecord[field.civiField] = value; } await civi("Activity", "create", { values: activityRecord }); return NextResponse.json({ ok: true }); }