Closes the file-upload gap. Files now actually land in CiviCRM (verified
empirically against the live Civi instance via spike scripts).
Spike findings (see scripts/spike-file-upload.mjs):
- APIv4 Attachment is NOT exposed on this Civi
- APIv4 File + EntityFile ARE exposed; File.create accepts inline
base64 `content` and returns a usable file id
- Custom file fields store the file id directly in the custom column,
so EntityFile linkage is unnecessary for this use case
- Round-trip via Contact.update + Contact.get .file_name join verified
on a real org contact
Pipeline:
Renderer (FileField) picks up onChange →
POST /api/upload (multipart) with file + cid + cs + fieldRef →
verifyChecksum, MIME allowlist + magic-byte sniff, 5 MB cap →
civi.File.create({ file_name, mime_type, content: base64 }) →
returns { id, file_name } →
renderer stores in RHF state via setValue
Form submit →
POST /api/submit (JSON) with the {id, file_name} value →
submit detects the file shape and writes the id as the value of
the activity/contact custom field
File changes:
app/api/upload/route.ts
Replaced the 501 stub with the real File.create call. Comment
documents that EntityFile linkage is intentionally skipped and that
orphan cleanup is owned by a CiviCRM scheduled job.
app/api/submit/route.ts
For type:"file" values shaped as {id, file_name}, write the id as
the custom field value (activity or contact, depending on the
civiField / civiContactField the field declares).
components/fields/FieldRenderer.tsx
Replaced the bare <input type=file> register() with FileField, an
upload-on-pick subcomponent. The native input is NOT register()'d:
its FileList value was the original bug. FileField owns its
uploading + error state and writes {id, file_name} via setValue on
success. Submit is blocked upstream while uploads are in flight.
components/StageSection.tsx, components/EngagementForm.tsx
Thread setValue, cid, cs, and an onUploadStateChange callback
through to FieldRenderer. EngagementForm tracks uploads-in-flight
count; onSubmit refuses to submit while the count is > 0.
config/form.ts
Promotes Certificate_of_Incorporation from readonly to a real
file field now that the pipeline works.
app/api/data/route.ts
Drops the readonly carveout that was only needed while the
certificate was readonly.
scripts/list-civi-entities.mjs (new)
APIv4 entity probe + APIv3 attachment-API probe. Used to determine
that File (not Attachment) was the right entity on this Civi.
scripts/spike-file-upload.mjs (new)
The actual end-to-end test that proved out the pipeline before
wiring. Safe to re-run on any Civi instance during future audits.
Not in this change:
- Orphan attachment cleanup (CiviCRM scheduled job, Civi admin scope)
- Per-field MIME allowlists (single global list for v1)
- S3 / presigned-URL path for >5 MB files (deferred; capped at 5 MB
today to stay under Amplify Lambda's 6 MB sync payload limit)
268 lines
10 KiB
TypeScript
268 lines
10 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;
|
|
|
|
// Org-contact custom fields configured on form fields. Each
|
|
// FieldConfig.civiContactField is `<group_name>.<field_name>` and is
|
|
// selected directly off the Organization Contact record.
|
|
const orgContactFieldRefs = allFields
|
|
.map((f) => f.civiContactField)
|
|
.filter((s): s is string => Boolean(s));
|
|
// For file-typed org-contact fields, also pull the joined .file_name so
|
|
// the prior-attachment indicator shows the filename, not just the file id.
|
|
const orgContactFileRefs = allFields
|
|
.filter((f) => f.type === "file" && f.civiContactField)
|
|
.map((f) => f.civiContactField!)
|
|
.filter(Boolean);
|
|
const orgContactSelect = [
|
|
"id",
|
|
"display_name",
|
|
...orgContactFieldRefs,
|
|
...orgContactFileRefs.map((ref) => `${ref}.file_name`),
|
|
];
|
|
|
|
// 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; [key: string]: unknown }>("Contact", "get", {
|
|
select: orgContactSelect,
|
|
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;
|
|
|
|
// Merge org-contact custom-field values into prefill, keyed by form-side
|
|
// field name. Activity-based prefill values take precedence only when an
|
|
// explicit non-empty entry exists — but org-contact fields don't appear
|
|
// in the activity walk, so collisions can't happen in practice.
|
|
for (const f of allFields) {
|
|
if (!f.civiContactField) continue;
|
|
const raw = org[f.civiContactField];
|
|
if (raw === null || raw === undefined || raw === "") continue;
|
|
if (f.type === "file" || f.type === "readonly") {
|
|
// File or readonly fields: surface as {id, file_name} so the renderer
|
|
// can show a filename indicator. Plain readonly text fields fall
|
|
// through to the else branch.
|
|
const fname = org[`${f.civiContactField}.file_name`];
|
|
if (typeof fname === "string" && fname) {
|
|
prefill[f.name] = { id: raw, file_name: fname };
|
|
continue;
|
|
}
|
|
}
|
|
prefill[f.name] = raw;
|
|
}
|
|
|
|
const payload: FormDataPayload = {
|
|
orgName: org.display_name,
|
|
currentStage,
|
|
contact,
|
|
prefill,
|
|
options,
|
|
};
|
|
return NextResponse.json(payload);
|
|
}
|