Derive current stage from most-recent stage-bearing activity

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.
This commit is contained in:
Joel Brock
2026-05-13 11:41:25 -07:00
parent 18b7a67fa1
commit b4e80517a7
4 changed files with 132 additions and 113 deletions

View File

@@ -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`;
/**