/** * GET /api/report?cid=&cs= * * Verifies the checksum, resolves the org from the contact via the * Primary Contact relationship, then walks every Check-in (organizing) * activity for the org and assembles a per-field history of non-empty * values plus a summary list of the activities themselves. * * Returns ReportPayload (see types/form.ts). * * STUB MODE: if CiviCRM env vars are unset, returns synthesized history * shaped from the same stub used by /api/data so the report UI is * exercisable without a live CRM. */ import { NextRequest, NextResponse } from "next/server"; import { civi, verifyChecksum } from "@/lib/civicrm"; import { allFields, optionGroupIds, ACTIVITY_TYPE_NAME, ACTIVITY_STAGE_FIELD, FORM_CONTACT_RELATIONSHIP, } from "@/config/form"; import type { ReportPayload, SelectOption, FieldHistoryEntry, ActivitySummary, } from "@/types/form"; function isStubMode(): boolean { return !( process.env.CIVI_BASE_URL && process.env.CIVI_API_KEY && process.env.CIVI_SITE_KEY ); } const STUB_PAYLOAD: ReportPayload = (() => { const today = new Date(); const daysAgo = (n: number) => new Date(today.getTime() - n * 24 * 3600 * 1000).toISOString(); return { orgName: "Sample Co-op (stub)", currentStage: "Organizing", activities: [ { id: 9012, date: daysAgo(3), subject: "Co-op Check-in (form submission)" }, { id: 9008, date: daysAgo(34), subject: "Co-op Check-in (form submission)" }, { id: 9001, date: daysAgo(62), stage: "Organizing", subject: "Stage transition (staff)" }, { id: 8995, date: daysAgo(95), subject: "Co-op Check-in (form submission)" }, { id: 8980, date: daysAgo(180), stage: "Inquiry", subject: "Initial check-in (staff)" }, ], fieldHistory: { Peer_Group_Participation: [ { activityId: 9012, date: daysAgo(3), value: "Yes" }, { activityId: 9008, date: daysAgo(34), value: "Considering" }, { activityId: 8995, date: daysAgo(95), value: "No" }, ], Members__current_: [ { activityId: 9012, date: daysAgo(3), value: 124 }, { activityId: 9008, date: daysAgo(34), value: 109 }, { activityId: 8995, date: daysAgo(95), value: 87 }, ], Member_Goal_for_current_Stage: [ { activityId: 9008, date: daysAgo(34), value: 200 }, ], Internal_Startup_Assessment: [ { activityId: 9012, date: daysAgo(3), value: "Strong" }, { activityId: 8995, date: daysAgo(95), value: "Moderate" }, ], }, options: { 140: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }, { value: "Considering", label: "Considering" }], 132: [{ value: "Strong", label: "Strong" }, { value: "Moderate", label: "Moderate" }, { value: "Needs Work", label: "Needs work" }], 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" }, ], }, }; })(); interface OptionValueRow { value: string; label: string; option_group_id: number; is_active: boolean; } async function fetchOptionGroups(): Promise> { if (optionGroupIds.length === 0) return {}; const res = await civi("OptionValue", "get", { select: ["value", "label", "option_group_id", "is_active"], where: [ ["option_group_id", "IN", optionGroupIds], ["is_active", "=", true], ], orderBy: { weight: "ASC" }, limit: 500, }); 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; } export async function GET(req: NextRequest) { const url = new URL(req.url); const cid = url.searchParams.get("cid"); const cs = url.searchParams.get("cs"); if (!cid || !cs) { return NextResponse.json({ error: "Missing cid or cs parameter." }, { status: 400 }); } if (isStubMode()) { return NextResponse.json(STUB_PAYLOAD); } const ok = await verifyChecksum(cid, cs); if (!ok) { return NextResponse.json( { error: "Link is invalid or has expired. Please request a fresh one." }, { status: 401 }, ); } // Resolve org (mirror of /api/data). const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", { select: ["contact_id_b"], where: [ ["contact_id_a", "=", Number(cid)], ["relationship_type_id.name_a_b", "=", FORM_CONTACT_RELATIONSHIP], ["is_active", "=", true], ], limit: 2, }); const orgs = relRes.values ?? []; if (orgs.length === 0) { return NextResponse.json( { error: `No active "${FORM_CONTACT_RELATIONSHIP}" relationship found for your contact.` }, { status: 404 }, ); } if (orgs.length > 1) { return NextResponse.json( { error: `Your contact has multiple active "${FORM_CONTACT_RELATIONSHIP}" relationships; staff must resolve before this link will work.` }, { status: 409 }, ); } const orgId = orgs[0].contact_id_b; // Org name + activity walk + option groups, in parallel. const civiFieldNames = Array.from( new Set(allFields.map((f) => f.civiField).filter((f): f is string => !!f)), ); const select = [ "id", "activity_date_time", "subject", ACTIVITY_STAGE_FIELD, ...civiFieldNames, ]; const [orgRes, activityRes, options] = await Promise.all([ civi<{ id: number; display_name: string }>("Contact", "get", { select: ["id", "display_name"], where: [["id", "=", orgId]], }), civi & { id: number; activity_date_time: string }>( "Activity", "get", { select, where: [ ["activity_type_id:name", "=", ACTIVITY_TYPE_NAME], ["target_contact_id", "=", orgId], ], orderBy: { activity_date_time: "DESC", id: "DESC" }, limit: 500, }, ), fetchOptionGroups(), ]); const org = orgRes.values?.[0]; if (!org) { return NextResponse.json({ error: "Organization not found." }, { status: 404 }); } const rows = activityRes.values ?? []; // Activity summaries — ordered DESC already. 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, })); // Derive current stage from most recent stage-bearing activity. 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) : "") || "Inquiry"; // Per-field history. For every form-side field that has a civiField, // walk activities and collect non-empty values. const fieldHistory: Record = {}; for (const f of allFields) { if (!f.civiField) continue; if (f.type === "readonly") continue; // readonly displays don't have history const entries: FieldHistoryEntry[] = []; for (const row of rows) { const v = row[f.civiField]; if (v === null || v === undefined || v === "") continue; entries.push({ activityId: row.id, date: row.activity_date_time, value: v, }); } if (entries.length > 0) fieldHistory[f.name] = entries; } const payload: ReportPayload = { orgName: org.display_name, currentStage, activities, fieldHistory, options, }; return NextResponse.json(payload); }