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:
247
app/api/report/route.ts
Normal file
247
app/api/report/route.ts
Normal 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
77
app/report/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user