Build standalone CiviCRM check-in middleware

This commit is contained in:
Joel Brock
2026-05-09 20:08:15 -07:00
parent 0899e6ae9a
commit 54555c74d2
21 changed files with 1894 additions and 457 deletions

124
app/api/data/route.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* GET /api/data?cid=<cid>&cs=<cs>
*
* Verifies the checksum, resolves the org from the contact via the
* Primary Form Contact relationship, reads the org's Framework Stage,
* and walks past Org Engagement Submission activities for per-field
* most-recent prefill.
*
* Returns FormDataPayload (see types/form.ts).
*
* STUB MODE: if CiviCRM env vars are unset, returns mock data so the UI
* is exercisable without a live CRM. The mock data uses the actual stage
* values and a couple of populated example fields so the form looks alive
* during development.
*/
import { NextRequest, NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm";
import { loadPrefill } from "@/lib/prefill";
import { allFields } from "@/config/form";
import type { FormDataPayload } from "@/types/form";
const STUB_PAYLOAD: FormDataPayload = {
orgName: "Sample Co-op (stub)",
currentStage: "Organizing",
prefill: {
stage_0_peer_group_participation: "Peer cohort 4",
stage_0_members_current: 87,
stage_0_total_members_at_opening: null,
stage_0_projected_y1_sales: 2400000,
stage_0_projected_y2_sales: 2950000,
stage_0_total_cost_of_project: 4200000,
stage_1_vision: "A neighborhood-rooted co-op grocery prioritizing local farmers and equity in food access.",
stage_1_business_concept: "5,500 sq ft full-service co-op in a transit-adjacent storefront.",
},
};
function isStubMode(): boolean {
return !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
);
}
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const cid = url.searchParams.get("cid");
const cs = url.searchParams.get("cs");
if (!cid || !cs) {
return NextResponse.json(
{ error: "Missing cid or cs parameter." },
{ status: 400 },
);
}
if (isStubMode()) {
return NextResponse.json(STUB_PAYLOAD);
}
// Verify checksum first.
const ok = await verifyChecksum(cid, cs);
if (!ok) {
return NextResponse.json(
{ error: "Link is invalid or has expired. Please request a fresh one." },
{ status: 401 },
);
}
// Resolve the org from the contact's Primary Form Contact relationship.
// Requires that the relationship exists and is active. We fetch contact_id_b
// because the relationship is Individual (A) → Organization (B).
const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", {
select: ["contact_id_b"],
where: [
["contact_id_a", "=", Number(cid)],
["relationship_type_id:name", "=", "Primary Form Contact of"],
["is_active", "=", true],
],
limit: 2,
});
const orgs = relRes.values ?? [];
if (orgs.length === 0) {
return NextResponse.json(
{ error: "No active Primary Form Contact relationship found for your contact." },
{ status: 404 },
);
}
if (orgs.length > 1) {
return NextResponse.json(
{ error: "Your contact has multiple active Primary Form Contact relationships; staff must resolve before this link will work." },
{ status: 409 },
);
}
const orgId = orgs[0].contact_id_b;
// Fetch org name + Framework Stage value.
const orgRes = await civi<{
id: number;
display_name: string;
"Food_Co_op_Organizing.Stage": string | null;
custom_1?: string | null;
}>("Contact", "get", {
select: ["id", "display_name", "custom_1", "Food_Co_op_Organizing.Stage"],
where: [["id", "=", orgId]],
});
const org = orgRes.values?.[0];
if (!org) {
return NextResponse.json({ error: "Organization not found." }, { status: 404 });
}
const currentStage =
org["Food_Co_op_Organizing.Stage"] ?? org.custom_1 ?? "";
// Per-field-most-recent prefill across past submission activities.
const { values: prefill } = await loadPrefill(orgId, allFields);
const payload: FormDataPayload = {
orgName: org.display_name,
currentStage: typeof currentStage === "string" ? currentStage : "",
prefill,
};
return NextResponse.json(payload);
}

109
app/api/submit/route.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* POST /api/submit
*
* Body: SubmitPayload { cid, cs, values }.
*
* Verifies checksum, resolves org from cid (same as /api/data), then creates a
* new Org Engagement Submission activity with all visible-field values written
* to their custom-field bindings + stage_at_submission set to the org's
* current Framework Stage.
*
* STUB MODE: if CiviCRM env vars are unset, returns success without writing
* anywhere. Useful for UI dev.
*/
import { NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm";
import { allFields } from "@/config/form";
import type { SubmitPayload } from "@/types/form";
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) {
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 Org Engagement Submission activity with values:", values);
return NextResponse.json({ ok: true, stub: true });
}
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", "=", "Primary Form Contact of"],
["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 stamp `stage_at_submission`.
const orgRes = await civi<{
"Food_Co_op_Organizing.Stage": string | null;
custom_1?: string | null;
}>("Contact", "get", {
select: ["custom_1", "Food_Co_op_Organizing.Stage"],
where: [["id", "=", orgId]],
});
const stageAtSubmission =
orgRes.values?.[0]?.["Food_Co_op_Organizing.Stage"] ??
orgRes.values?.[0]?.custom_1 ??
null;
// Build the activity record. Each form-side field name maps to its
// configured `civiField` for the activity-side write.
const activityRecord: Record<string, unknown> = {
"activity_type_id:name": "Org Engagement Submission",
"status_id:name": "Completed",
target_contact_id: orgId,
source_contact_id: Number(cid),
};
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;
}
// Stamp the stage-at-submission audit field. Convention: a field named
// `Submission_Audit.stage_at_submission` on the activity. Adjust to your
// actual machine name if different.
activityRecord["Submission_Audit.stage_at_submission"] = stageAtSubmission;
await civi("Activity", "create", { values: activityRecord });
return NextResponse.json({ ok: true });
}