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

@@ -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,
};

View File

@@ -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;
}