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.
This commit is contained in:
Joel Brock
2026-06-04 17:37:36 -07:00
parent b120075ca2
commit 8ecf64c79b
6 changed files with 205 additions and 12 deletions
+42 -2
View File
@@ -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 `<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
// 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,
+23 -4
View File
@@ -103,8 +103,11 @@ async function runSubmit(cid: string, cs: string, values: Record<string, unknown
}
const orgId = orgs[0].contact_id_b;
// Build the activity record. The Stage custom field is deliberately NOT
// set here — staff own stage transitions on their own activities.
// Split incoming values into:
// - activityRecord: fields with civiField → written to the new activity
// - orgContactValues: fields with civiContactField → written to the org
// contact via Contact.update
// Readonly fields are skipped entirely (display-only).
const activityRecord: Record<string, unknown> = {
"activity_type_id:name": ACTIVITY_TYPE_NAME,
"status_id:name": "Completed",
@@ -112,11 +115,27 @@ async function runSubmit(cid: string, cs: string, values: Record<string, unknown
source_contact_id: Number(cid),
subject: "Co-op Survey (form submission)",
};
const orgContactValues: Record<string, unknown> = {};
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 });