Staff report: API route with stub payload and key validation

This commit is contained in:
Joel Brock
2026-06-05 16:15:30 -07:00
parent 7cd5cd6cdf
commit 5a349a4f1c
+211
View File
@@ -0,0 +1,211 @@
/**
* GET /api/staff/report?org=<id>&key=<secret>
*
* 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 type { StaffReportPayload } from "@/types/form";
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" },
},
],
},
],
},
],
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));
}
// Live Civi branch added in Task 8.
return NextResponse.json({ error: "Live mode not yet implemented." }, { status: 501 });
}