Add /report — read-only activity history view

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.
This commit is contained in:
Joel Brock
2026-05-13 12:33:26 -07:00
parent 04e69ca04c
commit ba88eb0165
4 changed files with 978 additions and 0 deletions

247
app/api/report/route.ts Normal file
View File

@@ -0,0 +1,247 @@
/**
* 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);
}

77
app/report/page.tsx Normal file
View File

@@ -0,0 +1,77 @@
import { Suspense } from "react";
import { ReportView } from "@/components/ReportView";
import { SiteHeader, SiteFooter } from "@/components/SiteChrome";
import { formConfig } from "@/config/form";
interface PageProps {
searchParams: Promise<{ cid?: string; cs?: string }>;
}
export const metadata = {
title: "Activity report — Food Co-op Initiative",
robots: { index: false, follow: false },
};
export default async function ReportPage({ searchParams }: PageProps) {
const { cid, cs } = await searchParams;
return (
<>
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-paper focus:px-3 focus:py-2 focus:text-ink focus:shadow"
>
Skip to content
</a>
<SiteHeader />
<main id="main" className="flex-1">
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6 sm:py-14">
<ReportIntro />
{!cid || !cs ? (
<MissingLinkParams />
) : (
<Suspense fallback={null}>
<ReportView config={formConfig} cid={cid} cs={cs} />
</Suspense>
)}
</div>
</main>
<SiteFooter />
</>
);
}
function ReportIntro() {
return (
<header className="mb-10 max-w-2xl">
<p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700">
Activity report · Co-op organizing
</p>
<h1 className="mt-2 font-display text-[40px] font-normal leading-[1.05] tracking-tight text-ink sm:text-[52px]">
What we know so far
</h1>
<p className="mt-4 max-w-prose text-[17px] leading-relaxed text-ink-soft">
A read-only summary of every value your co-op has shared through past
check-ins, grouped by stage. The most recent entry sits at the top of
each row; expand a row to see how a number or note has changed over
time.
</p>
<div className="mt-6 h-px bg-rule" />
</header>
);
}
function MissingLinkParams() {
return (
<div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
<h2 className="font-display text-xl font-medium text-clay-700">
This link is missing required information.
</h2>
<p className="mt-3 leading-relaxed text-ink-soft">
Open the report using the personalized link from your email. If you no
longer have it, please contact your engagement coordinator and ask for
a fresh link.
</p>
</div>
);
}