From b05d7c77e3b97e2c454908a7741d6d30cdd498ec Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Fri, 5 Jun 2026 16:27:23 -0700 Subject: [PATCH] Staff report: live Civi branch (schema discovery + activity walk) --- app/api/staff/report/route.ts | 244 +++++++++++++++++++++++++++++++++- 1 file changed, 241 insertions(+), 3 deletions(-) diff --git a/app/api/staff/report/route.ts b/app/api/staff/report/route.ts index 88c6661..3a3a549 100644 --- a/app/api/staff/report/route.ts +++ b/app/api/staff/report/route.ts @@ -13,7 +13,44 @@ import { NextRequest, NextResponse } from "next/server"; import { isStaffKeyValid } from "@/lib/staff-auth"; -import type { StaffReportPayload } from "@/types/form"; +import { civi } from "@/lib/civicrm"; +import { mapCustomFieldRow } from "@/lib/staff-field-mapping.mjs"; +import type { + StaffReportPayload, + StaffFieldDescriptor, + StaffReportSection, + ActivitySummary, + FieldHistoryEntry, + SelectOption, +} from "@/types/form"; + +// CustomFieldRow comes from the JSDoc typedef in staff-field-mapping.mjs; +// we mirror it locally as a TS interface so the call sites are type-checked. +type CustomFieldRow = Record & { + name: string; + label: string; + data_type: string; + html_type: string; + option_group_id: number | null | undefined; + weight: number; + "custom_group_id.name": string; + "custom_group_id.title": string; +}; + +const ACTIVITY_TYPE_NAME = "Check-in (organizing)"; +const ACTIVITY_STAGE_FIELD = "Check_in_data__organizing_.Stage"; +const STAGE_OPTION_GROUP_ID = 75; + +const ACTIVITY_GROUP_NAMES = [ + "Check_in_data__organizing_", + "Stage_1", + "Stage_2", + "Stage_3", + "Stage_4", + "Stage_5", +]; +const ORG_GROUP_NAMES = ["Food_Co_op_Organizing"]; +const ALL_GROUP_NAMES = [...ACTIVITY_GROUP_NAMES, ...ORG_GROUP_NAMES]; function isCiviStubMode(): boolean { return !( @@ -206,6 +243,207 @@ export async function GET(req: NextRequest) { return NextResponse.json(buildStubPayload(orgId)); } - // Live Civi branch added in Task 8. - return NextResponse.json({ error: "Live mode not yet implemented." }, { status: 501 }); + try { + const payload = await buildLivePayload(orgId); + return NextResponse.json(payload); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[staff/report] live fetch failed:", msg); + return NextResponse.json( + { error: "Couldn't load the report. Check Civi credentials." }, + { status: 502 }, + ); + } +} + +async function buildLivePayload(orgId: number): Promise { + // 1. Discover fields. + const fieldsRes = await civi("CustomField", "get", { + select: [ + "name", + "label", + "data_type", + "html_type", + "option_group_id", + "weight", + "custom_group_id.name", + "custom_group_id.title", + ], + where: [ + ["custom_group_id.name", "IN", ALL_GROUP_NAMES], + ["is_active", "=", true], + ], + orderBy: { "custom_group_id.weight": "ASC", weight: "ASC" }, + limit: 500, + }); + + const descriptors: StaffFieldDescriptor[] = (fieldsRes.values ?? []).map((row) => { + const d = mapCustomFieldRow(row); + if (d.render === "text" && row.data_type !== "String" && row.data_type !== "Text") { + console.warn( + `[staff/report] field ${d.civiField} mapped to text fallback (data_type=${row.data_type} html_type=${row.html_type})`, + ); + } + return d; + }); + + const activityDescriptors = descriptors.filter((d) => d.groupKind === "activity"); + const orgDescriptors = descriptors.filter((d) => d.groupKind === "org"); + + // 2. Org Contact (display_name + every org-side custom field). + const orgSelect = ["id", "display_name", "contact_type", ...orgDescriptors.map((d) => d.civiField)]; + // 3. Activities (every activity-side custom field + file-name/url joins). + const fileFieldRefs = activityDescriptors + .filter((d) => d.render === "file") + .flatMap((d) => [`${d.civiField}.file_name`, `${d.civiField}.url`]); + const activitySelect = [ + "id", + "activity_date_time", + "subject", + "source_contact_id.display_name", + ACTIVITY_STAGE_FIELD, + ...activityDescriptors.map((d) => d.civiField), + ...fileFieldRefs, + ]; + // 4. Option groups for every select/multiselect + the stage option group. + const optionGroupIds = Array.from( + new Set([ + STAGE_OPTION_GROUP_ID, + ...descriptors.map((d) => d.optionGroupId).filter((id): id is number => typeof id === "number"), + ]), + ); + + const [orgRes, activityRes, options] = await Promise.all([ + civi & { id: number; display_name: string; contact_type: string }>( + "Contact", + "get", + { + select: orgSelect, + where: [["id", "=", orgId]], + }, + ), + civi & { id: number; activity_date_time: string }>("Activity", "get", { + select: activitySelect, + where: [ + ["activity_type_id:name", "=", ACTIVITY_TYPE_NAME], + ["target_contact_id", "=", orgId], + ], + orderBy: { activity_date_time: "DESC", id: "DESC" }, + limit: 500, + }), + fetchOptionGroups(optionGroupIds), + ]); + + const org = orgRes.values?.[0]; + if (!org || org.contact_type !== "Organization") { + throw new Error(`Org ${orgId} not found or not an Organization`); + } + + const rows = activityRes.values ?? []; + + // Activity summaries. + const activities: ActivitySummary[] = rows.map((r) => ({ + id: r.id, + date: r.activity_date_time, + stage: (r[ACTIVITY_STAGE_FIELD] as string | null | undefined) ?? null, + subject: (r.subject as string | null | undefined) ?? null, + submittedBy: + (r["source_contact_id.display_name"] as string | null | undefined) ?? null, + })); + + // Current stage. + const stageRow = rows.find((r) => { + const v = r[ACTIVITY_STAGE_FIELD]; + return typeof v === "string" && v.length > 0; + }); + const currentStage = + typeof stageRow?.[ACTIVITY_STAGE_FIELD] === "string" + ? (stageRow![ACTIVITY_STAGE_FIELD] as string) + : null; + + // Build sections in order: org first, then activity groups in ALL_GROUP_NAMES order. + const sections: StaffReportSection[] = []; + + // Org section (single section since we only have Food_Co_op_Organizing today). + if (orgDescriptors.length > 0) { + const groupName = orgDescriptors[0].groupName; + sections.push({ + groupName, + groupTitle: "Organization profile", + groupKind: "org", + fields: orgDescriptors.map((d) => { + const raw = org[d.civiField]; + const history: FieldHistoryEntry[] = + raw === null || raw === undefined || raw === "" + ? [] + : [{ activityId: 0, date: "", value: raw }]; + return { descriptor: d, history }; + }), + }); + } + + // Activity sections, in canonical order. + for (const groupName of ACTIVITY_GROUP_NAMES) { + const groupDescriptors = activityDescriptors.filter((d) => d.groupName === groupName); + if (groupDescriptors.length === 0) continue; + const groupTitle = groupDescriptors[0].groupTitle; + + sections.push({ + groupName, + groupTitle, + groupKind: "activity", + fields: groupDescriptors.map((d) => { + const entries: FieldHistoryEntry[] = []; + for (const row of rows) { + const v = row[d.civiField]; + if (v === null || v === undefined || v === "") continue; + let value: unknown = v; + if (d.render === "file") { + const fname = row[`${d.civiField}.file_name`]; + const url = row[`${d.civiField}.url`]; + value = { + id: v, + file_name: typeof fname === "string" ? fname : undefined, + url: typeof url === "string" ? url : undefined, + }; + } + entries.push({ activityId: row.id, date: row.activity_date_time, value }); + } + return { descriptor: d, history: entries }; + }), + }); + } + + return { + orgId, + orgName: org.display_name, + currentStage, + sections, + activities, + options, + }; +} + +async function fetchOptionGroups(ids: number[]): Promise> { + if (ids.length === 0) return {}; + const res = await civi<{ + value: string; + label: string; + option_group_id: number; + is_active: boolean; + }>("OptionValue", "get", { + select: ["value", "label", "option_group_id", "is_active"], + where: [ + ["option_group_id", "IN", ids], + ["is_active", "=", true], + ], + orderBy: { weight: "ASC" }, + limit: 1000, + }); + const out: Record = {}; + for (const row of res.values ?? []) { + if (!out[row.option_group_id]) out[row.option_group_id] = []; + out[row.option_group_id].push({ value: row.value, label: row.label }); + } + return out; }