From 8ecf64c79b4f114660aa1b0246a100bdb4864141 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Thu, 4 Jun 2026 17:37:36 -0700 Subject: [PATCH] Stage 0: org-contact custom fields (Food_Co_op_Organizing) Adds four fields from the Organization Contact's Food_Co_op_Organizing custom group to the Stage 0 (always-visible) section: Date Incorporated (date, editable) Name on Incorporation Certificate (text, editable) Certificate of Incorporation (readonly; see note) Equity share (currency, editable) These live on the Organization Contact record, not on the Check-in activity, so they read/write through a different code path: - FieldConfig gains civiContactField, mutually exclusive with civiField - /api/data extends the org Contact.get select to include them and merges values into the prefill payload keyed by form-side name - /api/submit splits incoming values: contact-bound fields go through Contact.update (run first), activity-bound fields stay in the Activity.create call (run second) - FieldRenderer readonly branch now detects file-shaped values ({id, file_name}) and displays the filename rather than [object Object] Certificate_of_Incorporation is wired readonly only: the form's file-upload pipeline is not actually wired end-to-end (FileList drops at JSON.stringify in onSubmit; no /api/upload endpoint exists). A follow-up will close that gap. Also adds scripts/inspect-org-custom-fields.mjs, a one-off introspection script for dumping CustomField metadata when wiring a new group. --- app/api/data/route.ts | 44 +++++++++++++++- app/api/submit/route.ts | 27 ++++++++-- components/fields/FieldRenderer.tsx | 24 +++++++-- config/form.ts | 40 +++++++++++++++ scripts/inspect-org-custom-fields.mjs | 72 +++++++++++++++++++++++++++ types/form.ts | 10 +++- 6 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 scripts/inspect-org-custom-fields.mjs diff --git a/app/api/data/route.ts b/app/api/data/route.ts index 61699df..027c673 100644 --- a/app/api/data/route.ts +++ b/app/api/data/route.ts @@ -163,12 +163,31 @@ export async function GET(req: NextRequest) { } const orgId = orgs[0].contact_id_b; + // Org-contact custom fields configured on form fields. Each + // FieldConfig.civiContactField is `.` 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 + // we can surface a human-readable filename in the readonly indicator. + const orgContactFileRefs = allFields + .filter((f) => f.type === "file" || (f.type === "readonly" && f.civiContactField?.toLowerCase().includes("certificate"))) + .map((f) => f.civiContactField) + .filter((s): s is string => Boolean(s)); + 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 }>("Contact", "get", { - select: ["id", "display_name"], + 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 @@ -216,6 +235,27 @@ export async function GET(req: NextRequest) { } : 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, diff --git a/app/api/submit/route.ts b/app/api/submit/route.ts index dde4046..e36edf4 100644 --- a/app/api/submit/route.ts +++ b/app/api/submit/route.ts @@ -103,8 +103,11 @@ async function runSubmit(cid: string, cs: string, values: Record = { "activity_type_id:name": ACTIVITY_TYPE_NAME, "status_id:name": "Completed", @@ -112,11 +115,27 @@ async function runSubmit(cid: string, cs: string, values: Record = {}; for (const [name, value] of Object.entries(values)) { const field = FIELD_BY_NAME.get(name); - if (!field || !field.civiField) continue; + if (!field) continue; if (field.type === "readonly") continue; // never write read-only fields - activityRecord[field.civiField] = value; + if (field.civiContactField) { + orgContactValues[field.civiContactField] = value; + } else if (field.civiField) { + activityRecord[field.civiField] = value; + } + } + + // Update the org contact first (if any contact-bound fields changed), + // then create the submission activity. Order matters: if the contact + // update fails we'd rather not have an orphan activity claiming the + // submission succeeded. + if (Object.keys(orgContactValues).length > 0) { + await civi("Contact", "update", { + where: [["id", "=", orgId]], + values: orgContactValues, + }); } await civi("Activity", "create", { values: activityRecord }); diff --git a/components/fields/FieldRenderer.tsx b/components/fields/FieldRenderer.tsx index 7b27b4f..4410ce8 100644 --- a/components/fields/FieldRenderer.tsx +++ b/components/fields/FieldRenderer.tsx @@ -66,11 +66,25 @@ export function FieldRenderer({ // ── Readonly display field ────────────────────────────────────────────── if (field.type === "readonly") { - const opt = effectiveOptions.find((o) => o.value === readonlyValue); - const display = - readonlyValue == null || readonlyValue === "" - ? "—" - : opt?.label ?? String(readonlyValue); + // File-shaped readonly value: {id, file_name} from a CiviCRM file field + // (e.g. Certificate of Incorporation). Render the filename rather than + // "[object Object]". + let display: string; + if ( + readonlyValue != null && + typeof readonlyValue === "object" && + !Array.isArray(readonlyValue) && + "file_name" in (readonlyValue as Record) + ) { + const fn = (readonlyValue as { file_name?: unknown }).file_name; + display = typeof fn === "string" && fn ? fn : "Attachment on file"; + } else { + const opt = effectiveOptions.find((o) => o.value === readonlyValue); + display = + readonlyValue == null || readonlyValue === "" + ? "—" + : opt?.label ?? String(readonlyValue); + } return (