Files
WebForm-mw/app/api/data/route.ts
T
Joel Brock 8766de5ed0 Copy: "member(s)" → "member-owner(s)" in user-facing strings
Sweeps every active label / help / intro / visible paragraph in the
app to use 'member-owner' terminology consistently. Stub option
labels for the Capital Stack (Member equity / Member loans) updated
on the label side; option `value:` strings stay as the CRM-side
stored values. Sentinel comparisons in ReportView's chart legend
updated to track the new field labels (otherwise the `(custom
label)` parenthetical would print spuriously even at default).

Untouched on purpose:
  - Civi machine names (`Members__current_`, `Member_*`,
    `*_Member*`) — wire-level identifiers, must match Civi.
  - Option-group `value:` strings — CRM-stored values, must match.
  - NCG_Member / INFRA_Member option labels — these refer to a
    co-op's membership in distributor networks, not member-owners.
  - Commented-out fields and technical comments referencing Civi
    field names.
2026-05-21 14:08:06 -07:00

228 lines
8.4 KiB
TypeScript

/**
* GET /api/data?cid=<cid>&cs=<cs>
*
* Verifies the checksum, resolves the org from the contact via the
* Primary Contact relationship, derives the org's current Framework Stage
* from the most recent `Check-in (organizing)` activity whose Stage custom
* field is non-empty (staff set this manually to mark transitions; the
* form itself leaves it null), and walks past 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 + 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,
optionGroupIds,
ACTIVITY_TYPE_NAME,
ACTIVITY_STAGE_FIELD,
FORM_CONTACT_RELATIONSHIP,
} from "@/config/form";
import type { FormDataPayload, SelectOption } from "@/types/form";
/**
* Fallback when an org has no stage-bearing check-in activity yet.
* Matches the entry stage in CiviCRM's Stage option group.
*/
const DEFAULT_STAGE = "Inquiry";
const STUB_PAYLOAD: FormDataPayload = {
orgName: "Sample Co-op (stub)",
currentStage: "Organizing",
contact: {
firstName: "Jordan",
lastName: "Sample",
email: "jordan.sample@example.coop",
},
prefill: {
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-Owner equity" }, { value: "Member loans", label: "Member-Owner 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" },
],
},
};
function isStubMode(): boolean {
return !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
);
}
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");
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 Contact" relationship.
// 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: [
["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 === 0) {
return NextResponse.json(
{ error: `No active "${FORM_CONTACT_RELATIONSHIP}" relationship found for your contact.` },
{ status: 404 },
);
}
if (orgs.length > 1) {
return NextResponse.json(
{ error: `Your contact has multiple active "${FORM_CONTACT_RELATIONSHIP}" relationships; staff must resolve before this link will work.` },
{ status: 409 },
);
}
const orgId = orgs[0].contact_id_b;
// Fire org-name lookup, contact-identity lookup, stage-bearing-activity
// lookup, prefill walk, and option-group fetch in parallel — they're
// independent.
const [orgRes, contactRes, stageActivityRes, { values: prefill }, options] = await Promise.all([
civi<{ id: number; display_name: string }>("Contact", "get", {
select: ["id", "display_name"],
where: [["id", "=", orgId]],
}),
// Identifying details for the form-filler. APIv4 lets us chain through
// the primary-email join in the same call.
civi<{
id: number;
first_name: string | null;
last_name: string | null;
"email_primary.email": string | null;
}>("Contact", "get", {
select: ["id", "first_name", "last_name", "email_primary.email"],
where: [["id", "=", Number(cid)]],
}),
// Most recent Check-in (organizing) activity whose Stage custom field
// is set. Staff own this field; the form never writes it. Tiebreaker on
// equal activity_date_time is id DESC.
civi<{ id: number; [key: string]: unknown }>("Activity", "get", {
select: ["id", ACTIVITY_STAGE_FIELD],
where: [
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
["target_contact_id", "=", orgId],
[ACTIVITY_STAGE_FIELD, "IS NOT EMPTY"],
],
orderBy: { activity_date_time: "DESC", id: "DESC" },
limit: 1,
}),
loadPrefill(orgId, allFields, ACTIVITY_TYPE_NAME),
fetchOptionGroups(),
]);
const org = orgRes.values?.[0];
if (!org) {
return NextResponse.json({ error: "Organization not found." }, { status: 404 });
}
const stageRaw = stageActivityRes.values?.[0]?.[ACTIVITY_STAGE_FIELD];
const currentStage = typeof stageRaw === "string" && stageRaw ? stageRaw : DEFAULT_STAGE;
const contactRow = contactRes.values?.[0];
const contact = contactRow
? {
firstName: contactRow.first_name ?? "",
lastName: contactRow.last_name ?? "",
email: contactRow["email_primary.email"] ?? "",
}
: undefined;
const payload: FormDataPayload = {
orgName: org.display_name,
currentStage,
contact,
prefill,
options,
};
return NextResponse.json(payload);
}