/** * GET /api/staff/report?org=&key= * * Returns a comprehensive read-only StaffReportPayload for the given * organization. Field list is built at request time from * CustomField.get (no static config). * * Auth: STAFF_REPORT_KEY must match the `key` query param. * * STUB MODE: if CiviCRM env vars are unset, returns a fabricated payload * exercising every render kind so the staff page is usable in dev. */ import { NextRequest, NextResponse } from "next/server"; import { isStaffKeyValid } from "@/lib/staff-auth"; import { civi, civi3 } 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 !( process.env.CIVI_BASE_URL && process.env.CIVI_API_KEY && process.env.CIVI_SITE_KEY ); } function buildStubPayload(orgId: number): StaffReportPayload { const today = new Date(); const daysAgo = (n: number) => new Date(today.getTime() - n * 24 * 3600 * 1000).toISOString(); const ymd = (n: number) => { const d = new Date(today.getTime() - n * 24 * 3600 * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; }; return { orgId, orgName: "Sample Co-op (stub)", currentStage: "Organizing", sections: [ { groupName: "Food_Co_op_Organizing", groupTitle: "Organization profile", groupKind: "org", fields: [ { descriptor: { groupName: "Food_Co_op_Organizing", groupTitle: "Organization profile", groupKind: "org", civiField: "Food_Co_op_Organizing.Date_Incorporated", name: "Date_Incorporated", label: "Date Incorporated", render: "date", }, history: [{ activityId: 0, date: "", value: ymd(900) }], }, { descriptor: { groupName: "Food_Co_op_Organizing", groupTitle: "Organization profile", groupKind: "org", civiField: "Food_Co_op_Organizing.Equity_share", name: "Equity_share", label: "Equity share (USD)", render: "currency", }, history: [{ activityId: 0, date: "", value: 200 }], }, ], }, { groupName: "Check_in_data__organizing_", groupTitle: "Check-in data (organizing)", groupKind: "activity", fields: [ { descriptor: { groupName: "Check_in_data__organizing_", groupTitle: "Check-in data (organizing)", groupKind: "activity", civiField: "Check_in_data__organizing_.Members__current_", name: "Members__current_", label: "Members (current)", render: "number", }, history: [ { activityId: 9012, date: daysAgo(3), value: 124 }, { activityId: 9008, date: daysAgo(34), value: 109 }, { activityId: 8995, date: daysAgo(95), value: 87 }, ], }, { descriptor: { groupName: "Check_in_data__organizing_", groupTitle: "Check-in data (organizing)", groupKind: "activity", civiField: "Check_in_data__organizing_.Member_Goal_for_current_Stage", name: "Member_Goal_for_current_Stage", label: "Member goal for current stage", render: "number", }, history: [{ activityId: 9008, date: daysAgo(34), value: 200 }], }, { descriptor: { groupName: "Check_in_data__organizing_", groupTitle: "Check-in data (organizing)", groupKind: "activity", civiField: "Check_in_data__organizing_.Peer_Group_Participation", name: "Peer_Group_Participation", label: "Peer group participation", render: "select", optionGroupId: 140, }, history: [ { activityId: 9012, date: daysAgo(3), value: "Yes" }, { activityId: 9008, date: daysAgo(34), value: "Considering" }, ], }, { descriptor: { groupName: "Check_in_data__organizing_", groupTitle: "Check-in data (organizing)", groupKind: "activity", civiField: "Check_in_data__organizing_.Internal_Note", name: "Internal_Note", label: "Internal note", render: "longtext", }, history: [ { activityId: 9012, date: daysAgo(3), value: "Strong member momentum this quarter. Need a working group lead before next check-in.", }, ], }, ], }, { groupName: "Stage_1", groupTitle: "Stage 1", groupKind: "activity", fields: [ { descriptor: { groupName: "Stage_1", groupTitle: "Stage 1", groupKind: "activity", civiField: "Stage_1.Vision_Upload", name: "Vision_Upload", label: "Vision — Upload", render: "file", }, history: [ { activityId: 9012, date: daysAgo(3), value: { id: 4242, file_name: "co-op-vision.pdf", mime: "application/pdf" }, }, ], }, ], }, ], activities: [ { id: 9012, date: daysAgo(3), subject: "Co-op Survey (form submission)", submittedBy: "Jane Doe" }, { id: 9008, date: daysAgo(34), subject: "Co-op Survey (form submission)", submittedBy: "Jane Doe" }, { id: 9001, date: daysAgo(62), stage: "Organizing", subject: "Stage transition (staff)" }, { id: 8995, date: daysAgo(95), subject: "Co-op Survey (form submission)", submittedBy: "John Roe" }, ], options: { 140: [ { value: "Yes", label: "Yes" }, { value: "No", label: "No" }, { value: "Considering", label: "Considering" }, ], 75: [ { value: "Inquiry", label: "Inquiry" }, { value: "Organizing", label: "Stage 1 — Convene & Prepare" }, { value: "Feasibility", label: "Stage 2 — Grow & Plan" }, { value: "Business feasibility", label: "Stage 3 — Connect & Gather" }, { value: "Store Implementation", label: "Stage 4 — Excite & Build" }, { value: "Stabilize newly opened co-op", label: "Stage 5 — Fulfill & Stabilize" }, ], }, }; } export async function GET(req: NextRequest) { const url = new URL(req.url); const key = url.searchParams.get("key"); const orgStr = url.searchParams.get("org"); if (!isStaffKeyValid(key)) { // Don't leak whether the route exists. return new NextResponse("Not found", { status: 404 }); } const orgId = Number(orgStr); if (!orgStr || !Number.isFinite(orgId) || orgId <= 0) { return NextResponse.json({ error: "Missing or invalid org id." }, { status: 400 }); } if (isCiviStubMode()) { return NextResponse.json(buildStubPayload(orgId)); } 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, plus file-name // joins for any file-typed org fields so the staff report can render a // label next to the link). const orgFileNameRefs = orgDescriptors .filter((d) => d.render === "file") .map((d) => `${d.civiField}.file_name`); const orgSelect = [ "id", "display_name", "contact_type", ...orgDescriptors.map((d) => d.civiField), ...orgFileNameRefs, ]; // 3. Activities (every activity-side custom field + file-name joins). const activityFileNameRefs = activityDescriptors .filter((d) => d.render === "file") .map((d) => `${d.civiField}.file_name`); const activitySelect = [ "id", "activity_date_time", "subject", "source_contact_id.display_name", ACTIVITY_STAGE_FIELD, ...activityDescriptors.map((d) => d.civiField), ...activityFileNameRefs, ]; // 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 ?? []; // Civi serves uploaded files at /civicrm/file?id=X&fcs=; the fcs is // an HS256 JWT signed with the site key. Without it, /civicrm/file // crashes on a null JWT decode. APIv4 Attachment isn't exposed on this // install, but APIv3 Attachment.get is — and it returns `url` with the // fcs already baked in. We pass the URL straight through to the client. const fileIds = new Set(); const collectId = (v: unknown) => { if (v === null || v === undefined || v === "") return; const n = typeof v === "number" ? v : Number(v); if (Number.isFinite(n) && n > 0) fileIds.add(n); }; for (const d of activityDescriptors) { if (d.render !== "file") continue; for (const row of rows) collectId(row[d.civiField]); } for (const d of orgDescriptors) { if (d.render !== "file") continue; collectId(org[d.civiField]); } const urlByFileId = new Map(); const mimeByFileId = new Map(); if (fileIds.size > 0) { // APIv3 Attachment.get doesn't accept an IN-clause cleanly on this Civi // install — passing {IN: [...]} for `id` crashes Civi's error renderer // on htmlentities(array). The same call with `id: ` works // (verified in API Explorer), so we loop one call per file id. Reports // typically reference a handful of files, so the round-trip cost is // small. Each request is independent; we issue them in parallel. const lookups = await Promise.allSettled( Array.from(fileIds).map((fid) => civi3<{ id: string | number; url?: string; mime_type?: string }>( "Attachment", "get", { id: fid, return: "id,url,mime_type", sequential: 1, }, ).then((r) => ({ fid, row: r.values?.[0] })), ), ); for (const result of lookups) { if (result.status === "rejected") { console.warn( "[staff/report] Attachment.get (v3) failed for one file:", result.reason instanceof Error ? result.reason.message : String(result.reason), ); continue; } const { fid, row } = result.value; if (row && typeof row.url === "string" && row.url.length > 0) { urlByFileId.set(fid, row.url); } if (row && typeof row.mime_type === "string" && row.mime_type.length > 0) { mimeByFileId.set(fid, row.mime_type); } } } // 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]; if (raw === null || raw === undefined || raw === "") { return { descriptor: d, history: [] }; } let value: unknown = raw; if (d.render === "file") { const fid = Number(raw); const fname = org[`${d.civiField}.file_name`]; value = { id: raw, file_name: typeof fname === "string" ? fname : undefined, url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined, mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined, }; } return { descriptor: d, history: [{ activityId: 0, date: "", value }], }; }), }); } // 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 fid = Number(v); value = { id: v, file_name: typeof fname === "string" ? fname : undefined, url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined, mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : 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; }