Mirrors the form's IA and Field Almanac aesthetic; same auth (cid/cs
checksum) so the org owner who can fill the form can also view its
history.
New routes:
- GET /api/report — verifies checksum, resolves the org via the
Primary Contact relationship, fires Contact.get + Activity.get +
OptionValue.get in parallel. For every form field with a civiField,
walks all the org's Check-in (organizing) activities and collects
every non-empty value into a sorted-DESC history list. Returns
ReportPayload (orgName, currentStage, activities, fieldHistory,
options). Has stub-mode payload for env-less local dev.
- /report — page entry; same layout shell (SiteHeader + SiteFooter,
3xl page width). Eyebrow "Activity report - Co-op organizing".
ReportView component:
- ReportContextHeader: large org name, progress dots + uppercase
"Current stage" eyebrow + the Civi option *label* on its own line
at display-font xl/2xl leaf-800 (matches the form's header). Below
it a 3-up stat band: total check-ins, fields tracked, date span.
- One accordion card per stage section, in stage-rank order. Only
sections that have at least one field-with-entries render — past,
current, or "future-with-data" all welcome; truly empty stages stay
hidden so the page is calm.
- Same journey rail (md+) and mobile stem (md-) with past =
check-filled-leaf, current = filled-leaf-with-ring, future = dashed
hollow ring; solid leaf line vs dashed muted between markers.
- Within each card: divide-y rows. Field label and help on the left,
most-recent value on the right in display-font lg leaf-800, dated
beneath with an "{N} earlier entries" disclosure that expands a
small vertical timeline (date on left, value on right).
- FormattedValue handles currency (Intl), percent, number (tabular
nums), date (long, timezone-safe for YYYY-MM-DD), boolean (Yes/No),
select/readonly (resolved via option group), multiselect (handles
array or delimited string), file (filename), text-like (as-is).
- Loading / empty / error states match the form's treatments.
types/form.ts: new FieldHistoryEntry, ActivitySummary, ReportPayload.
The fieldHistory map keys by FieldConfig.name and only includes fields
that have at least one non-empty entry.
248 lines
7.9 KiB
TypeScript
248 lines
7.9 KiB
TypeScript
/**
|
|
* GET /api/report?cid=<cid>&cs=<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<Record<number, SelectOption[]>> {
|
|
if (optionGroupIds.length === 0) return {};
|
|
const res = await civi<OptionValueRow>("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<number, SelectOption[]> = {};
|
|
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<Record<string, unknown> & { 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<string, FieldHistoryEntry[]> = {};
|
|
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);
|
|
}
|