Wire real CiviCRM DEV custom fields and dynamic option fetching
This commit is contained in:
@@ -2,36 +2,60 @@
|
||||
* 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.
|
||||
* Primary Form Contact relationship, reads the org's Framework Stage
|
||||
* (`Food_Co_op_Organizing.Stage` on the Organization contact), and walks
|
||||
* past `Check-in (organizing)` activities for per-field most-recent prefill.
|
||||
*
|
||||
* Also fetches the OptionValue rows for any option_group_ids referenced by
|
||||
* the form (so radio/select/multiselect fields render with real CRM-defined
|
||||
* options) and returns them in `payload.options`.
|
||||
*
|
||||
* 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.
|
||||
* STUB MODE: if CiviCRM env vars are unset, returns mock data + a small set
|
||||
* of mock options so the UI is exercisable without a live CRM.
|
||||
*/
|
||||
|
||||
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";
|
||||
import { allFields, optionGroupIds, ACTIVITY_TYPE_NAME } from "@/config/form";
|
||||
import type { FormDataPayload, SelectOption } 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.",
|
||||
Peer_Group_Participation: "Yes",
|
||||
Members__current_: 87,
|
||||
Total_members_at_opening: null,
|
||||
Projected_Year_1_Sales: 2400000,
|
||||
Projected_Year_2_Sales: 2950000,
|
||||
Total_cost_of_project: 4200000,
|
||||
Vision: "2024-09-15",
|
||||
Business_Concept: "2024-12-02",
|
||||
},
|
||||
options: {
|
||||
140: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }, { value: "Considering", label: "Considering" }],
|
||||
132: [{ value: "Strong", label: "Strong" }, { value: "Moderate", label: "Moderate" }, { value: "Needs Work", label: "Needs work" }],
|
||||
141: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }],
|
||||
142: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }],
|
||||
133: [{ value: "Viable", label: "Viable" }, { value: "Marginal", label: "Marginal" }, { value: "Not viable", label: "Not viable" }],
|
||||
134: [{ value: "Member equity", label: "Member equity" }, { value: "Member loans", label: "Member loans" }, { value: "Bank debt", label: "Bank debt" }, { value: "Grants", label: "Grants" }],
|
||||
139: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }],
|
||||
135: [{ value: "Co-op", label: "Co-op grocery" }, { value: "Conventional", label: "Conventional grocery" }, { value: "Other", label: "Other" }],
|
||||
136: [{ value: "Member", label: "Member" }, { value: "Considering", label: "Considering" }, { value: "Not a member", label: "Not a member" }],
|
||||
137: [{ value: "Member", label: "Member" }, { value: "Considering", label: "Considering" }, { value: "Not a member", label: "Not a member" }],
|
||||
138: [{ value: "UNFI", label: "UNFI" }, { value: "KeHE", label: "KeHE" }, { value: "Other", label: "Other" }, { value: "None", label: "None" }],
|
||||
143: [{ value: "Northeast", label: "Northeast" }, { value: "Mid-Atlantic", label: "Mid-Atlantic" }, { value: "Midwest", label: "Midwest" }, { value: "South", label: "South" }, { value: "West", label: "West" }],
|
||||
75: [
|
||||
{ value: "Inquiry", label: "Inquiry" },
|
||||
{ value: "Organizing", label: "Stage 1 — Convene & Prepare" },
|
||||
{ value: "Feasibility", label: "Stage 2 — Grow & Plan" },
|
||||
{ value: "Business feasibility", label: "Stage 3 — Connect & Gather" },
|
||||
{ value: "Store Implementation", label: "Stage 4 — Excite & Build" },
|
||||
{ value: "Stabilize newly opened co-op", label: "Stage 5 — Fulfill & Stabilize" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,6 +67,32 @@ function isStubMode(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionValueRow {
|
||||
value: string;
|
||||
label: string;
|
||||
option_group_id: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
async function fetchOptionGroups(): Promise<Record<number, SelectOption[]>> {
|
||||
if (optionGroupIds.length === 0) return {};
|
||||
const res = await civi<OptionValueRow>("OptionValue", "get", {
|
||||
select: ["value", "label", "option_group_id", "is_active"],
|
||||
where: [
|
||||
["option_group_id", "IN", optionGroupIds],
|
||||
["is_active", "=", true],
|
||||
],
|
||||
orderBy: { weight: "ASC" },
|
||||
limit: 500,
|
||||
});
|
||||
const out: Record<number, SelectOption[]> = {};
|
||||
for (const row of res.values ?? []) {
|
||||
if (!out[row.option_group_id]) out[row.option_group_id] = [];
|
||||
out[row.option_group_id].push({ value: row.value, label: row.label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const cid = url.searchParams.get("cid");
|
||||
@@ -69,8 +119,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// The relationship is Individual (A) → Organization (B); fetch contact_id_b.
|
||||
const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", {
|
||||
select: ["contact_id_b"],
|
||||
where: [
|
||||
@@ -95,30 +144,33 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
const orgId = orgs[0].contact_id_b;
|
||||
|
||||
// Fetch org name + Framework Stage value.
|
||||
// Fetch org name + Framework Stage (org-side, on the organization contact).
|
||||
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"],
|
||||
select: ["id", "display_name", "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 ?? "";
|
||||
const currentStage = org["Food_Co_op_Organizing.Stage"] ?? "";
|
||||
|
||||
// Per-field-most-recent prefill across past submission activities.
|
||||
const { values: prefill } = await loadPrefill(orgId, allFields);
|
||||
// Per-field-most-recent prefill across past Check-in activities, plus
|
||||
// option-group fetch — kicked off in parallel.
|
||||
const [{ values: prefill }, options] = await Promise.all([
|
||||
loadPrefill(orgId, allFields, ACTIVITY_TYPE_NAME),
|
||||
fetchOptionGroups(),
|
||||
]);
|
||||
|
||||
const payload: FormDataPayload = {
|
||||
orgName: org.display_name,
|
||||
currentStage: typeof currentStage === "string" ? currentStage : "",
|
||||
prefill,
|
||||
options,
|
||||
};
|
||||
return NextResponse.json(payload);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
*
|
||||
* 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.
|
||||
* 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
|
||||
* anywhere. Useful for UI dev.
|
||||
* 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 } from "@/config/form";
|
||||
import { allFields, ACTIVITY_TYPE_NAME, ACTIVITY_STAGE_FIELD } from "@/config/form";
|
||||
import type { SubmitPayload } from "@/types/form";
|
||||
|
||||
function isStubMode(): boolean {
|
||||
@@ -40,16 +40,13 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
if (isStubMode()) {
|
||||
console.warn("[submit:STUB] would create Org Engagement Submission activity with values:", values);
|
||||
console.warn("[submit:STUB] would create Check-in (organizing) 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 },
|
||||
);
|
||||
return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 });
|
||||
}
|
||||
|
||||
// Resolve org via the relationship (mirror of /api/data).
|
||||
@@ -71,39 +68,37 @@ export async function POST(req: Request) {
|
||||
}
|
||||
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;
|
||||
// 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<string, unknown> = {
|
||||
"activity_type_id:name": "Org Engagement Submission",
|
||||
"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;
|
||||
}
|
||||
// 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 });
|
||||
await civi("Activity", "create", { values: [activityRecord] });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user