From b4e80517a725463dd2630453d08586efa1b7a553 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Wed, 13 May 2026 11:41:25 -0700 Subject: [PATCH] Derive current stage from most-recent stage-bearing activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage authority moves from Organization.Food_Co_op_Organizing.Stage to the most recent Check-in (organizing) activity whose Stage custom field is set. Staff create these activities manually to mark transitions; org-owner form submissions no longer write the Stage field at all, so they cannot override a staff-set transition. - /api/data: removed the Contact.get for org-side Stage; added an Activity.get filtered to ACTIVITY_TYPE_NAME + ACTIVITY_STAGE_FIELD IS NOT EMPTY, ordered by activity_date_time DESC, id DESC, limit 1. Fallback when no such activity exists: Inquiry (rank 0). Org-name lookup, stage activity, prefill, and option-group fetch all run in parallel via Promise.all. - /api/submit: removed the stageAtSubmission read + the [ACTIVITY_STAGE_FIELD] write on the activity record. The form's activities are stage-null by design now. - config/form.ts: dropped the stage_at_submission readonly field (no longer being set or displayed). Kept ACTIVITY_STAGE_FIELD export — it's now used by /api/data to find stage-bearing activities. Updated the current_stage field comment to reflect the new source. - components/EngagementForm.tsx: dropped stage_at_submission from evalState (no longer referenced by any visibility rule or readonly display). Org.Food_Co_op_Organizing.Stage remains in CiviCRM for staff list views; the middleware no longer reads or writes it. No backfill required — orgs without a stage-bearing activity simply read as Inquiry. --- app/api/data/route.ts | 70 +++++++++++------ app/api/submit/route.ts | 27 ++----- components/EngagementForm.tsx | 10 +-- config/form.ts | 138 ++++++++++++++++++---------------- 4 files changed, 132 insertions(+), 113 deletions(-) diff --git a/app/api/data/route.ts b/app/api/data/route.ts index e8e45e8..879d5c1 100644 --- a/app/api/data/route.ts +++ b/app/api/data/route.ts @@ -2,9 +2,11 @@ * GET /api/data?cid=&cs= * * Verifies the checksum, resolves the org from the contact via the - * Primary Contact relationship, reads the org's Framework Stage - * (`Food_Co_op_Organizing.Stage` on the Organization contact), and walks - * past `Check-in (organizing)` activities for per-field most-recent prefill. + * 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 @@ -19,9 +21,21 @@ import { NextRequest, NextResponse } from "next/server"; import { civi, verifyChecksum } from "@/lib/civicrm"; import { loadPrefill } from "@/lib/prefill"; -import { allFields, optionGroupIds, ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form"; +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", @@ -144,31 +158,41 @@ export async function GET(req: NextRequest) { } const orgId = orgs[0].contact_id_b; - // Fetch org name + Framework Stage (org-side, on the organization contact). - const orgRes = await civi<{ - id: number; - display_name: string; - "Food_Co_op_Organizing.Stage": string | null; - }>("Contact", "get", { - select: ["id", "display_name", "Food_Co_op_Organizing.Stage"], - where: [["id", "=", orgId]], - }); - const org = orgRes.values?.[0]; - if (!org) { - return NextResponse.json({ error: "Organization not found." }, { status: 404 }); - } - const currentStage = org["Food_Co_op_Organizing.Stage"] ?? ""; - - // Per-field-most-recent prefill across past Check-in activities, plus - // option-group fetch — kicked off in parallel. - const [{ values: prefill }, options] = await Promise.all([ + // Fire org-name lookup, stage-bearing-activity lookup, prefill walk, and + // option-group fetch in parallel — they're independent. + const [orgRes, stageActivityRes, { values: prefill }, options] = await Promise.all([ + civi<{ id: number; display_name: string }>("Contact", "get", { + select: ["id", "display_name"], + where: [["id", "=", orgId]], + }), + // 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 payload: FormDataPayload = { orgName: org.display_name, - currentStage: typeof currentStage === "string" ? currentStage : "", + currentStage, prefill, options, }; diff --git a/app/api/submit/route.ts b/app/api/submit/route.ts index 0fa345e..6dce70b 100644 --- a/app/api/submit/route.ts +++ b/app/api/submit/route.ts @@ -5,16 +5,17 @@ * * Verifies checksum, resolves org from cid (mirror of /api/data), then * creates a new `Check-in (organizing)` activity. The activity's own Stage - * field (`Check_in_data__organizing_.Stage`) is auto-populated from the - * org's current `Food_Co_op_Organizing.Stage` so each check-in carries a - * snapshot of where the org was at submission time. + * field is intentionally left null: the form is not the authority on stage. + * Staff set Stage on their own check-in activities to mark transitions, and + * /api/data derives the org's current stage from the most recent stage- + * bearing activity. * * STUB MODE: if CiviCRM env vars are unset, returns success without writing. */ import { NextResponse } from "next/server"; import { civi, verifyChecksum } from "@/lib/civicrm"; -import { allFields, ACTIVITY_TYPE_NAME, ACTIVITY_STAGE_FIELD, FORM_CONTACT_RELATIONSHIP } from "@/config/form"; +import { allFields, ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form"; import type { SubmitPayload } from "@/types/form"; import { appEnv, redact } from "@/lib/env"; import { rateLimit, clientIp } from "@/lib/rate-limit"; @@ -102,33 +103,19 @@ async function runSubmit(cid: string, cs: string, values: Record( - "Contact", - "get", - { - select: ["Food_Co_op_Organizing.Stage"], - where: [["id", "=", orgId]], - }, - ); - const stageAtSubmission = orgRes.values?.[0]?.["Food_Co_op_Organizing.Stage"] ?? null; - - // Build the activity record. Each form-side field name maps to its - // configured `civiField` for the activity-side write. + // Build the activity record. The Stage custom field is deliberately NOT + // set here — staff own stage transitions on their own activities. const activityRecord: Record = { "activity_type_id:name": ACTIVITY_TYPE_NAME, "status_id:name": "Completed", target_contact_id: orgId, source_contact_id: Number(cid), subject: "Co-op Check-in (form submission)", - [ACTIVITY_STAGE_FIELD]: stageAtSubmission, }; for (const [name, value] of Object.entries(values)) { const field = FIELD_BY_NAME.get(name); if (!field || !field.civiField) continue; if (field.type === "readonly") continue; // never write read-only fields - if (name === "current_stage") continue; // org-side, never written to activity - if (name === "stage_at_submission") continue; // handled above via ACTIVITY_STAGE_FIELD activityRecord[field.civiField] = value; } diff --git a/components/EngagementForm.tsx b/components/EngagementForm.tsx index cc6e595..89a461a 100644 --- a/components/EngagementForm.tsx +++ b/components/EngagementForm.tsx @@ -76,13 +76,11 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) { // State map passed to the conditional engine and to FieldRenderer for // readonly display values. Only fields referenced by visibility rules or - // by readonly displays need to be here. Both readonly fields in this form - // (current_stage, stage_at_submission) display the org's current stage. + // by readonly displays need to be here — currently just current_stage, + // which gates every Stage 1–5 visibleWhen rule and is shown as a readonly + // field in the Stage 0 header. const evalState = useMemo( - () => ({ - current_stage: currentStageValue, - stage_at_submission: currentStageValue, - }), + () => ({ current_stage: currentStageValue }), [currentStageValue], ); diff --git a/config/form.ts b/config/form.ts index c4716a9..0f18628 100644 --- a/config/form.ts +++ b/config/form.ts @@ -50,20 +50,15 @@ const stage0: StageSectionConfig = { intro: "Core check-in fields. These are visible at every stage and capture the data we follow over time across the lifecycle of the co-op.", fields: [ - // current_stage is a special UI-only field — it shows the org's stage. - // It is NOT written back to the activity by the submit endpoint. + // current_stage is a UI-only readonly field. Its value is sourced by + // /api/data from the most recent Check-in (organizing) activity whose + // Stage custom field is non-empty (staff create these manually when an + // org transitions). The form never writes Stage back to the activity — + // org-owner submissions create activities with Stage left null. { name: "current_stage", label: "Framework Stage", type: "readonly", - civiField: "Food_Co_op_Organizing.Stage", - }, - { - name: "stage_at_submission", - label: "This check-in's stage", - type: "readonly", - civiField: `${G0}.Stage`, - help: "Set automatically to the org's current Framework Stage when you submit.", }, { name: "Peer_Group_Participation", @@ -106,6 +101,7 @@ const stage0: StageSectionConfig = { label: "Total members at opening", type: "number", civiField: `${G0}.Total_members_at_opening`, + visibleWhen: visibleAtOrAfter(S.Stabilize), }, { name: "Volunteers_Helping_In_Store", @@ -129,6 +125,7 @@ const stage0: StageSectionConfig = { type: "number", civiField: `${G0}.Total_square_ft`, step: 1, + visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), }, { name: "Retail_sq_ft", @@ -136,6 +133,7 @@ const stage0: StageSectionConfig = { type: "number", civiField: `${G0}.Retail_sq_ft`, step: 1, + visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), }, { name: "Latest_Sources_and_Uses_doc", @@ -162,6 +160,7 @@ const stage0: StageSectionConfig = { name: "FTEs", label: "Projected FTEs", type: "number", + visibleWhen: visibleAtOrAfter(S.BusinessFeasibility, S.StoreImplementation, S.Stabilize), civiField: `${G0}.FTEs`, step: 0.1, help: "Number of full-time equivalent (FTE) employees planned to work at the store.", @@ -172,17 +171,19 @@ const stage0: StageSectionConfig = { type: "number", civiField: `${G0}.Actual_FTE`, step: 0.1, + visibleWhen: visibleAtOrAfter(S.Stabilize), }, { name: "Total_cost_of_project", label: "Total cost of project", type: "currency", + visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Total_cost_of_project`, }, { name: "Member_equity_total", label: "Member equity total needed", type: "currency", civiField: `${G0}.Member_equity_total` }, - { name: "Member_equity_raised", label: "Member equity raised", type: "currency", civiField: `${G0}.Member_equity_raised` }, + { name: "Member_equity_raised", label: "Member equity raised", type: "currency", visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Member_equity_raised` }, { name: "Member_loans_total", label: "Member loans total needed", type: "currency", civiField: `${G0}.Member_loans_total` }, - { name: "Member_loans_raised", label: "Member loans raised", type: "currency", civiField: `${G0}.Member_loans_raised` }, + { name: "Member_loans_raised", label: "Member loans raised", type: "currency", visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Member_loans_raised` }, { name: "Member_preferred_shares_total", label: "Member preferred shares total needed", @@ -193,14 +194,15 @@ const stage0: StageSectionConfig = { name: "Member_preferred_shares_raised", label: "Member preferred shares raised", type: "currency", + visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Member_preferred_shares_raised`, }, { name: "Bank_debt_total", label: "Bank debt total needed", type: "currency", civiField: `${G0}.Bank_debt_total` }, - { name: "Bank_debt_raised", label: "Bank debt raised", type: "currency", civiField: `${G0}.Bank_debt_raised` }, + { name: "Bank_debt_raised", label: "Bank debt raised", type: "currency", visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Bank_debt_raised` }, { name: "Grant_Donations_Needed", label: "Grants / Donations Needed", type: "currency", civiField: `${G0}.Grant_Donations_Needed` }, - { name: "Grants_Donations_Raised", label: "Grants / Donations Raised", type: "currency", civiField: `${G0}.Grants_Donations_Raised` }, + { name: "Grants_Donations_Raised", label: "Grants / Donations Raised", type: "currency", visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Grants_Donations_Raised` }, { name: "Other_sources_total", label: "Other sources total needed", type: "currency", civiField: `${G0}.Other_sources_total` }, - { name: "Other_sources_raised", label: "Other sources raised", type: "currency", civiField: `${G0}.Other_sources_raised` }, + { name: "Other_sources_raised", label: "Other sources raised", type: "currency", visibleWhen: visibleAtOrAfter(S.Stabilize), civiField: `${G0}.Other_sources_raised` }, { name: "Date_Closed_Folded", label: "Date Closed / Folded", type: "date", civiField: `${G0}.Date_Closed_Folded` }, ] as FieldConfig[], }; @@ -216,6 +218,7 @@ const stage1: StageSectionConfig = { name: "Preliminary_Market_Assessment", label: "Preliminary Market Assessment", type: "date", + required: true, civiField: `${G1}.Preliminary_Market_Assessment`, help: "What date was your Preliminary Market Assessment completed?", }, @@ -449,10 +452,8 @@ const stage4: StageSectionConfig = { // Stage 5 — Fulfill & Stabilize. Field machine names have historical // inconsistencies; we list each one explicitly rather than building // programmatically to keep the mapping auditable. +/* const STAGE_5_FIELDS: FieldConfig[] = [ - { name: "Date_Opened", label: "Date Opened", type: "date", civiField: `${G5}.Date_Opened` }, - { name: "Y1_Actual_Sales", label: "Y1 Actual Sales", type: "currency", civiField: `${G5}.Y1_Actual_Sales` }, - // Y1 Monthly Sales Targets — note the irregular machine names! { name: "Y1_Monthly_Sales_Targets", label: "Y1 Monthly Sales Target: M1", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Targets` }, { name: "Y1_Monthly_Sales_Targets_M2", label: "Y1 Monthly Sales Target: M2", type: "currency", civiField: `${G5}.Y1_Monthly_Sales_Targets_M2` }, @@ -532,57 +533,61 @@ const Y1_TARGET_FIELDS = [ "Y1_Monthly_Sales_Target_M11", "Y1_Monthly_Sales_Target_M12", ]; - +*/ const stage5: StageSectionConfig = { rank: 5, id: "stage_5", label: "Stage 5 — Fulfill and Stabilize", visibleWhen: visibleAtOrAfter(S.Stabilize), - fields: STAGE_5_FIELDS, - matrixGroups: [ - { - id: "y1_monthly_metrics", - label: "Year 1 — Monthly Tracking", - intro: "Targets and actuals by month. Leave blank for months you don't yet have data for.", - cols: monthCols, - rows: [ - { label: "Sales Target", type: "currency", fields: Y1_TARGET_FIELDS }, - { - label: "Actual Sales", - type: "currency", - fields: months.map((m) => `Y1_M${m}_Actual_Sales`), - }, - { - label: "Transactions", - type: "number", - fields: months.map((m) => `Y1_M${m}_Transactions`), - }, - ], - }, - { - id: "y1_quarterly_metrics", - label: "Year 1 — Quarterly Tracking", - intro: "Quarterly operating ratios.", - cols: quarterCols, - rows: [ - { - label: "Labor", - type: "percent", - fields: quarters.map((q) => `Y1_Q${q}_Labor`), - }, - { - label: "Margin", - type: "percent", - fields: quarters.map((q) => `Y1_Q${q}_Margin`), - }, - { - label: "Member Sales", - type: "percent", - fields: quarters.map((q) => `Y1_Q${q}_Member_Sales_`), - }, - ], - }, + fields: [ + { name: "Date_Opened", label: "Date Opened", type: "date", civiField: `${G5}.Date_Opened` }, + { name: "Y1_Actual_Sales", label: "Y1 Actual Sales", type: "currency", civiField: `${G5}.Y1_Actual_Sales` } ], + // STAGE_5_FIELDS, + // matrixGroups: [ +// { +// id: "y1_monthly_metrics", +// label: "Year 1 — Monthly Tracking", +// intro: "Targets and actuals by month. Leave blank for months you don't yet have data for.", +// cols: monthCols, +// rows: [ +// { label: "Sales Target", type: "currency", fields: Y1_TARGET_FIELDS }, +// { +// label: "Actual Sales", +// type: "currency", +// fields: months.map((m) => `Y1_M${m}_Actual_Sales`), +// }, +// { +// label: "Transactions", +// type: "number", +// fields: months.map((m) => `Y1_M${m}_Transactions`), +// }, +// ], +// }, +// { +// id: "y1_quarterly_metrics", +// label: "Year 1 — Quarterly Tracking", +// intro: "Quarterly operating ratios.", +// cols: quarterCols, +// rows: [ +// { +// label: "Labor", +// type: "percent", +// fields: quarters.map((q) => `Y1_Q${q}_Labor`), +// }, +// { +// label: "Margin", +// type: "percent", +// fields: quarters.map((q) => `Y1_Q${q}_Margin`), +// }, +// { +// label: "Member Sales", +// type: "percent", +// fields: quarters.map((q) => `Y1_Q${q}_Member_Sales_`), +// }, +// ], +// }, +// ], }; export const formConfig: FormConfig = { @@ -600,7 +605,12 @@ export const allFields = formConfig.sections.flatMap((s) => s.fields); /** Activity type the form writes its submissions as. */ export const ACTIVITY_TYPE_NAME = "Check-in (organizing)"; -/** Stage-snapshot field on the activity (so the form's stage_at_submission writes here). */ +/** + * Stage-snapshot field on the activity. The form does NOT write this field; + * it's set manually by staff on dedicated stage-change check-in activities. + * /api/data uses this field to derive the org's current stage by finding + * the most recent activity where it is non-empty. + */ export const ACTIVITY_STAGE_FIELD = `${G0}.Stage`; /**