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:
+42
-2
@@ -163,12 +163,31 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
const orgId = orgs[0].contact_id_b;
|
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
|
// Fire org-name lookup, contact-identity lookup, stage-bearing-activity
|
||||||
// lookup, prefill walk, and option-group fetch in parallel — they're
|
// lookup, prefill walk, and option-group fetch in parallel — they're
|
||||||
// independent.
|
// independent.
|
||||||
const [orgRes, contactRes, stageActivityRes, { values: prefill }, options] = await Promise.all([
|
const [orgRes, contactRes, stageActivityRes, { values: prefill }, options] = await Promise.all([
|
||||||
civi<{ id: number; display_name: string }>("Contact", "get", {
|
civi<{ id: number; display_name: string; [key: string]: unknown }>("Contact", "get", {
|
||||||
select: ["id", "display_name"],
|
select: orgContactSelect,
|
||||||
where: [["id", "=", orgId]],
|
where: [["id", "=", orgId]],
|
||||||
}),
|
}),
|
||||||
// Identifying details for the form-filler. APIv4 lets us chain through
|
// Identifying details for the form-filler. APIv4 lets us chain through
|
||||||
@@ -216,6 +235,27 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: 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 = {
|
const payload: FormDataPayload = {
|
||||||
orgName: org.display_name,
|
orgName: org.display_name,
|
||||||
currentStage,
|
currentStage,
|
||||||
|
|||||||
+23
-4
@@ -103,8 +103,11 @@ async function runSubmit(cid: string, cs: string, values: Record<string, unknown
|
|||||||
}
|
}
|
||||||
const orgId = orgs[0].contact_id_b;
|
const orgId = orgs[0].contact_id_b;
|
||||||
|
|
||||||
// Build the activity record. The Stage custom field is deliberately NOT
|
// Split incoming values into:
|
||||||
// set here — staff own stage transitions on their own activities.
|
// - 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> = {
|
const activityRecord: Record<string, unknown> = {
|
||||||
"activity_type_id:name": ACTIVITY_TYPE_NAME,
|
"activity_type_id:name": ACTIVITY_TYPE_NAME,
|
||||||
"status_id:name": "Completed",
|
"status_id:name": "Completed",
|
||||||
@@ -112,11 +115,27 @@ async function runSubmit(cid: string, cs: string, values: Record<string, unknown
|
|||||||
source_contact_id: Number(cid),
|
source_contact_id: Number(cid),
|
||||||
subject: "Co-op Survey (form submission)",
|
subject: "Co-op Survey (form submission)",
|
||||||
};
|
};
|
||||||
|
const orgContactValues: Record<string, unknown> = {};
|
||||||
for (const [name, value] of Object.entries(values)) {
|
for (const [name, value] of Object.entries(values)) {
|
||||||
const field = FIELD_BY_NAME.get(name);
|
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
|
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 });
|
await civi("Activity", "create", { values: activityRecord });
|
||||||
|
|||||||
@@ -66,11 +66,25 @@ export function FieldRenderer({
|
|||||||
|
|
||||||
// ── Readonly display field ──────────────────────────────────────────────
|
// ── Readonly display field ──────────────────────────────────────────────
|
||||||
if (field.type === "readonly") {
|
if (field.type === "readonly") {
|
||||||
const opt = effectiveOptions.find((o) => o.value === readonlyValue);
|
// File-shaped readonly value: {id, file_name} from a CiviCRM file field
|
||||||
const display =
|
// (e.g. Certificate of Incorporation). Render the filename rather than
|
||||||
readonlyValue == null || readonlyValue === ""
|
// "[object Object]".
|
||||||
? "—"
|
let display: string;
|
||||||
: opt?.label ?? String(readonlyValue);
|
if (
|
||||||
|
readonlyValue != null &&
|
||||||
|
typeof readonlyValue === "object" &&
|
||||||
|
!Array.isArray(readonlyValue) &&
|
||||||
|
"file_name" in (readonlyValue as Record<string, unknown>)
|
||||||
|
) {
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label id={id} field={field} />
|
<Label id={id} field={field} />
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ const G3 = "Stage_3";
|
|||||||
const G4 = "Stage_4";
|
const G4 = "Stage_4";
|
||||||
const G5 = "Stage_5";
|
const G5 = "Stage_5";
|
||||||
|
|
||||||
|
// Organization-contact custom group ("Food_Co_op_Organizing"). Fields here
|
||||||
|
// live on the Organization Contact, not on the Check-in activity, so they
|
||||||
|
// route through Contact.update on submit and Contact.get at form load.
|
||||||
|
const G_ORG = "Food_Co_op_Organizing";
|
||||||
|
|
||||||
// Stage 0 — Survey (always visible)
|
// Stage 0 — Survey (always visible)
|
||||||
const stage0: StageSectionConfig = {
|
const stage0: StageSectionConfig = {
|
||||||
rank: 0,
|
rank: 0,
|
||||||
@@ -86,6 +91,41 @@ const stage0: StageSectionConfig = {
|
|||||||
name: "contact_email",
|
name: "contact_email",
|
||||||
label: "Email",
|
label: "Email",
|
||||||
type: "readonly",
|
type: "readonly",
|
||||||
|
},
|
||||||
|
// Org-contact fields (Food_Co_op_Organizing custom group). These live
|
||||||
|
// on the Organization Contact record, not on the Check-in activity, so
|
||||||
|
// they read/write via Contact.get / Contact.update instead of the
|
||||||
|
// activity prefill walk.
|
||||||
|
{
|
||||||
|
name: "Date_Incorporated",
|
||||||
|
label: "Date Incorporated",
|
||||||
|
type: "date",
|
||||||
|
civiContactField: `${G_ORG}.Date_Incorporated`,
|
||||||
|
help: "The date this co-op was legally incorporated.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Name_on_Incorporation_Certificate",
|
||||||
|
label: "Name on Incorporation Certificate",
|
||||||
|
type: "text",
|
||||||
|
civiContactField: `${G_ORG}.Name_on_Incorporation_Certificate`,
|
||||||
|
help: "The legal name as it appears on the incorporation certificate.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Read-only until the form gains a file-upload pipeline. The field
|
||||||
|
// is shown as the prior attachment filename; fresh uploads aren't
|
||||||
|
// supported because /api/submit serializes as JSON (FileList drops).
|
||||||
|
name: "Certificate_of_Incorporation",
|
||||||
|
label: "Certificate of Incorporation",
|
||||||
|
type: "readonly",
|
||||||
|
civiContactField: `${G_ORG}.Certificate_of_Incorporation`,
|
||||||
|
help: "Contact staff to update the certificate on file.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Equity_share",
|
||||||
|
label: "Equity share",
|
||||||
|
type: "currency",
|
||||||
|
civiContactField: `${G_ORG}.Equity_share`,
|
||||||
|
help: "The cost of a single member-owner equity share.",
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// name: "Peer_Group_Participation",
|
// name: "Peer_Group_Participation",
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// scripts/inspect-org-custom-fields.mjs
|
||||||
|
//
|
||||||
|
// Dumps the CustomField metadata for the "Food_Co_op_Organizing" custom
|
||||||
|
// group (attached to Organization contacts) so we can wire the right
|
||||||
|
// machine names + types into config/form.ts.
|
||||||
|
//
|
||||||
|
// USAGE
|
||||||
|
// node --env-file=.env.local scripts/inspect-org-custom-fields.mjs
|
||||||
|
|
||||||
|
const GROUP = "Food_Co_op_Organizing";
|
||||||
|
|
||||||
|
async function civi(entity, action, params) {
|
||||||
|
const {
|
||||||
|
CIVI_BASE_URL,
|
||||||
|
CIVI_API_KEY,
|
||||||
|
CIVI_SITE_KEY,
|
||||||
|
CIVI_HTTP_AUTH_USER,
|
||||||
|
CIVI_HTTP_AUTH_PASS,
|
||||||
|
} = process.env;
|
||||||
|
if (!CIVI_BASE_URL || !CIVI_API_KEY || !CIVI_SITE_KEY) {
|
||||||
|
throw new Error("Missing CIVI_BASE_URL / CIVI_API_KEY / CIVI_SITE_KEY.");
|
||||||
|
}
|
||||||
|
const url = `${CIVI_BASE_URL}/civicrm/ajax/api4/${entity}/${action}`;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"X-Civi-Auth": `Bearer ${CIVI_API_KEY}`,
|
||||||
|
"X-Civi-Key": CIVI_SITE_KEY,
|
||||||
|
};
|
||||||
|
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
|
||||||
|
headers["Authorization"] =
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: new URLSearchParams({ params: JSON.stringify(params) }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`${entity}.${action} HTTP ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await civi("CustomField", "get", {
|
||||||
|
select: [
|
||||||
|
"name",
|
||||||
|
"label",
|
||||||
|
"data_type",
|
||||||
|
"html_type",
|
||||||
|
"is_active",
|
||||||
|
"option_group_id",
|
||||||
|
"custom_group_id.name",
|
||||||
|
"custom_group_id.extends",
|
||||||
|
],
|
||||||
|
where: [["custom_group_id.name", "=", GROUP]],
|
||||||
|
orderBy: { weight: "ASC" },
|
||||||
|
limit: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = result.values ?? [];
|
||||||
|
console.log(`Custom group: ${GROUP}`);
|
||||||
|
if (rows.length) {
|
||||||
|
console.log(`Extends: ${rows[0]["custom_group_id.extends"]}`);
|
||||||
|
}
|
||||||
|
console.log(`Found ${rows.length} fields:\n`);
|
||||||
|
for (const f of rows) {
|
||||||
|
console.log(
|
||||||
|
`- name=${f.name} label="${f.label}" data_type=${f.data_type} html_type=${f.html_type} option_group_id=${f.option_group_id ?? "-"} active=${f.is_active}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+9
-1
@@ -84,9 +84,17 @@ export interface FieldConfig {
|
|||||||
/**
|
/**
|
||||||
* The CiviCRM custom-field reference. APIv4 format: `<group_name>.<field_name>`.
|
* The CiviCRM custom-field reference. APIv4 format: `<group_name>.<field_name>`.
|
||||||
* Leave undefined for fields that don't write back to Civi (e.g. transient
|
* Leave undefined for fields that don't write back to Civi (e.g. transient
|
||||||
* UI helpers).
|
* UI helpers). Use this for fields that target the per-submission Activity
|
||||||
|
* record (Check_in_data__organizing_, Stage_1..5).
|
||||||
*/
|
*/
|
||||||
civiField?: string;
|
civiField?: string;
|
||||||
|
/**
|
||||||
|
* Like `civiField`, but for fields that live on the **Organization Contact**
|
||||||
|
* (e.g. `Food_Co_op_Organizing.Date_Incorporated`). /api/data reads these
|
||||||
|
* from the org contact at form-load time; /api/submit writes them back via
|
||||||
|
* Contact.update on submit. Mutually exclusive with `civiField`.
|
||||||
|
*/
|
||||||
|
civiContactField?: string;
|
||||||
/**
|
/**
|
||||||
* If this field's options come from a CiviCRM option group, set its ID here.
|
* If this field's options come from a CiviCRM option group, set its ID here.
|
||||||
* `/api/data` will fetch the option-group values and embed them in
|
* `/api/data` will fetch the option-group values and embed them in
|
||||||
|
|||||||
Reference in New Issue
Block a user