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:
@@ -2,9 +2,11 @@
|
||||
* GET /api/data?cid=<cid>&cs=<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,
|
||||
};
|
||||
|
||||
@@ -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<string, unknown
|
||||
}
|
||||
const orgId = orgs[0].contact_id_b;
|
||||
|
||||
// Read the org's current Framework Stage so we can snapshot it on the activity.
|
||||
const orgRes = await civi<{ "Food_Co_op_Organizing.Stage": string | null }>(
|
||||
"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<string, unknown> = {
|
||||
"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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
138
config/form.ts
138
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`;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user