Adds a /api/staff/file proxy that re-streams Civi attachments with Content-Disposition: inline so a native <dialog> lightbox can preview images and PDFs in place. Office docs keep their plain download link and gain a "View in Google Docs" secondary link (uses the Civi-signed URL so Google can fetch without our staff key). Also threads mime through /api/staff/report (Attachment.get mime_type) so the dispatcher picks the right affordance without relying solely on filename inference.
535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
/**
|
|
* 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 { civi, civi3 } from "@/lib/civicrm";
|
|
import { mapCustomFieldRow } from "@/lib/staff-field-mapping.mjs";
|
|
import type {
|
|
StaffReportPayload,
|
|
StaffFieldDescriptor,
|
|
StaffReportSection,
|
|
ActivitySummary,
|
|
FieldHistoryEntry,
|
|
SelectOption,
|
|
} from "@/types/form";
|
|
|
|
// CustomFieldRow comes from the JSDoc typedef in staff-field-mapping.mjs;
|
|
// we mirror it locally as a TS interface so the call sites are type-checked.
|
|
type CustomFieldRow = Record<string, unknown> & {
|
|
name: string;
|
|
label: string;
|
|
data_type: string;
|
|
html_type: string;
|
|
option_group_id: number | null | undefined;
|
|
weight: number;
|
|
"custom_group_id.name": string;
|
|
"custom_group_id.title": string;
|
|
};
|
|
|
|
const ACTIVITY_TYPE_NAME = "Check-in (organizing)";
|
|
const ACTIVITY_STAGE_FIELD = "Check_in_data__organizing_.Stage";
|
|
const STAGE_OPTION_GROUP_ID = 75;
|
|
|
|
const ACTIVITY_GROUP_NAMES = [
|
|
"Check_in_data__organizing_",
|
|
"Stage_1",
|
|
"Stage_2",
|
|
"Stage_3",
|
|
"Stage_4",
|
|
"Stage_5",
|
|
];
|
|
const ORG_GROUP_NAMES = ["Food_Co_op_Organizing"];
|
|
const ALL_GROUP_NAMES = [...ACTIVITY_GROUP_NAMES, ...ORG_GROUP_NAMES];
|
|
|
|
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", mime: "application/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));
|
|
}
|
|
|
|
try {
|
|
const payload = await buildLivePayload(orgId);
|
|
return NextResponse.json(payload);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error("[staff/report] live fetch failed:", msg);
|
|
return NextResponse.json(
|
|
{ error: "Couldn't load the report. Check Civi credentials." },
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
}
|
|
|
|
async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|
// 1. Discover fields.
|
|
const fieldsRes = await civi<CustomFieldRow>("CustomField", "get", {
|
|
select: [
|
|
"name",
|
|
"label",
|
|
"data_type",
|
|
"html_type",
|
|
"option_group_id",
|
|
"weight",
|
|
"custom_group_id.name",
|
|
"custom_group_id.title",
|
|
],
|
|
where: [
|
|
["custom_group_id.name", "IN", ALL_GROUP_NAMES],
|
|
["is_active", "=", true],
|
|
],
|
|
orderBy: { "custom_group_id.weight": "ASC", weight: "ASC" },
|
|
limit: 500,
|
|
});
|
|
|
|
const descriptors: StaffFieldDescriptor[] = (fieldsRes.values ?? []).map((row) => {
|
|
const d = mapCustomFieldRow(row);
|
|
if (d.render === "text" && row.data_type !== "String" && row.data_type !== "Text") {
|
|
console.warn(
|
|
`[staff/report] field ${d.civiField} mapped to text fallback (data_type=${row.data_type} html_type=${row.html_type})`,
|
|
);
|
|
}
|
|
return d;
|
|
});
|
|
|
|
const activityDescriptors = descriptors.filter((d) => d.groupKind === "activity");
|
|
const orgDescriptors = descriptors.filter((d) => d.groupKind === "org");
|
|
|
|
// 2. Org Contact (display_name + every org-side custom field, plus file-name
|
|
// joins for any file-typed org fields so the staff report can render a
|
|
// label next to the link).
|
|
const orgFileNameRefs = orgDescriptors
|
|
.filter((d) => d.render === "file")
|
|
.map((d) => `${d.civiField}.file_name`);
|
|
const orgSelect = [
|
|
"id",
|
|
"display_name",
|
|
"contact_type",
|
|
...orgDescriptors.map((d) => d.civiField),
|
|
...orgFileNameRefs,
|
|
];
|
|
// 3. Activities (every activity-side custom field + file-name joins).
|
|
const activityFileNameRefs = activityDescriptors
|
|
.filter((d) => d.render === "file")
|
|
.map((d) => `${d.civiField}.file_name`);
|
|
const activitySelect = [
|
|
"id",
|
|
"activity_date_time",
|
|
"subject",
|
|
"source_contact_id.display_name",
|
|
ACTIVITY_STAGE_FIELD,
|
|
...activityDescriptors.map((d) => d.civiField),
|
|
...activityFileNameRefs,
|
|
];
|
|
// 4. Option groups for every select/multiselect + the stage option group.
|
|
const optionGroupIds = Array.from(
|
|
new Set([
|
|
STAGE_OPTION_GROUP_ID,
|
|
...descriptors.map((d) => d.optionGroupId).filter((id): id is number => typeof id === "number"),
|
|
]),
|
|
);
|
|
|
|
const [orgRes, activityRes, options] = await Promise.all([
|
|
civi<Record<string, unknown> & { id: number; display_name: string; contact_type: string }>(
|
|
"Contact",
|
|
"get",
|
|
{
|
|
select: orgSelect,
|
|
where: [["id", "=", orgId]],
|
|
},
|
|
),
|
|
civi<Record<string, unknown> & { id: number; activity_date_time: string }>("Activity", "get", {
|
|
select: activitySelect,
|
|
where: [
|
|
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
|
|
["target_contact_id", "=", orgId],
|
|
],
|
|
orderBy: { activity_date_time: "DESC", id: "DESC" },
|
|
limit: 500,
|
|
}),
|
|
fetchOptionGroups(optionGroupIds),
|
|
]);
|
|
|
|
const org = orgRes.values?.[0];
|
|
if (!org || org.contact_type !== "Organization") {
|
|
throw new Error(`Org ${orgId} not found or not an Organization`);
|
|
}
|
|
|
|
const rows = activityRes.values ?? [];
|
|
|
|
// Civi serves uploaded files at /civicrm/file?id=X&fcs=<JWT>; the fcs is
|
|
// an HS256 JWT signed with the site key. Without it, /civicrm/file
|
|
// crashes on a null JWT decode. APIv4 Attachment isn't exposed on this
|
|
// install, but APIv3 Attachment.get is — and it returns `url` with the
|
|
// fcs already baked in. We pass the URL straight through to the client.
|
|
const fileIds = new Set<number>();
|
|
const collectId = (v: unknown) => {
|
|
if (v === null || v === undefined || v === "") return;
|
|
const n = typeof v === "number" ? v : Number(v);
|
|
if (Number.isFinite(n) && n > 0) fileIds.add(n);
|
|
};
|
|
for (const d of activityDescriptors) {
|
|
if (d.render !== "file") continue;
|
|
for (const row of rows) collectId(row[d.civiField]);
|
|
}
|
|
for (const d of orgDescriptors) {
|
|
if (d.render !== "file") continue;
|
|
collectId(org[d.civiField]);
|
|
}
|
|
|
|
const urlByFileId = new Map<number, string>();
|
|
const mimeByFileId = new Map<number, string>();
|
|
if (fileIds.size > 0) {
|
|
// APIv3 Attachment.get doesn't accept an IN-clause cleanly on this Civi
|
|
// install — passing {IN: [...]} for `id` crashes Civi's error renderer
|
|
// on htmlentities(array). The same call with `id: <single>` works
|
|
// (verified in API Explorer), so we loop one call per file id. Reports
|
|
// typically reference a handful of files, so the round-trip cost is
|
|
// small. Each request is independent; we issue them in parallel.
|
|
const lookups = await Promise.allSettled(
|
|
Array.from(fileIds).map((fid) =>
|
|
civi3<{ id: string | number; url?: string; mime_type?: string }>(
|
|
"Attachment",
|
|
"get",
|
|
{
|
|
id: fid,
|
|
return: "id,url,mime_type",
|
|
sequential: 1,
|
|
},
|
|
).then((r) => ({ fid, row: r.values?.[0] })),
|
|
),
|
|
);
|
|
for (const result of lookups) {
|
|
if (result.status === "rejected") {
|
|
console.warn(
|
|
"[staff/report] Attachment.get (v3) failed for one file:",
|
|
result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
);
|
|
continue;
|
|
}
|
|
const { fid, row } = result.value;
|
|
if (row && typeof row.url === "string" && row.url.length > 0) {
|
|
urlByFileId.set(fid, row.url);
|
|
}
|
|
if (row && typeof row.mime_type === "string" && row.mime_type.length > 0) {
|
|
mimeByFileId.set(fid, row.mime_type);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Activity summaries.
|
|
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,
|
|
submittedBy:
|
|
(r["source_contact_id.display_name"] as string | null | undefined) ?? null,
|
|
}));
|
|
|
|
// Current stage.
|
|
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)
|
|
: null;
|
|
|
|
// Build sections in order: org first, then activity groups in ALL_GROUP_NAMES order.
|
|
const sections: StaffReportSection[] = [];
|
|
|
|
// Org section (single section since we only have Food_Co_op_Organizing today).
|
|
if (orgDescriptors.length > 0) {
|
|
const groupName = orgDescriptors[0].groupName;
|
|
sections.push({
|
|
groupName,
|
|
groupTitle: "Organization profile",
|
|
groupKind: "org",
|
|
fields: orgDescriptors.map((d) => {
|
|
const raw = org[d.civiField];
|
|
if (raw === null || raw === undefined || raw === "") {
|
|
return { descriptor: d, history: [] };
|
|
}
|
|
let value: unknown = raw;
|
|
if (d.render === "file") {
|
|
const fid = Number(raw);
|
|
const fname = org[`${d.civiField}.file_name`];
|
|
value = {
|
|
id: raw,
|
|
file_name: typeof fname === "string" ? fname : undefined,
|
|
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
|
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
|
|
};
|
|
}
|
|
return {
|
|
descriptor: d,
|
|
history: [{ activityId: 0, date: "", value }],
|
|
};
|
|
}),
|
|
});
|
|
}
|
|
|
|
// Activity sections, in canonical order.
|
|
for (const groupName of ACTIVITY_GROUP_NAMES) {
|
|
const groupDescriptors = activityDescriptors.filter((d) => d.groupName === groupName);
|
|
if (groupDescriptors.length === 0) continue;
|
|
const groupTitle = groupDescriptors[0].groupTitle;
|
|
|
|
sections.push({
|
|
groupName,
|
|
groupTitle,
|
|
groupKind: "activity",
|
|
fields: groupDescriptors.map((d) => {
|
|
const entries: FieldHistoryEntry[] = [];
|
|
for (const row of rows) {
|
|
const v = row[d.civiField];
|
|
if (v === null || v === undefined || v === "") continue;
|
|
let value: unknown = v;
|
|
if (d.render === "file") {
|
|
const fname = row[`${d.civiField}.file_name`];
|
|
const fid = Number(v);
|
|
value = {
|
|
id: v,
|
|
file_name: typeof fname === "string" ? fname : undefined,
|
|
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
|
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
|
|
};
|
|
}
|
|
entries.push({ activityId: row.id, date: row.activity_date_time, value });
|
|
}
|
|
return { descriptor: d, history: entries };
|
|
}),
|
|
});
|
|
}
|
|
|
|
return {
|
|
orgId,
|
|
orgName: org.display_name,
|
|
currentStage,
|
|
sections,
|
|
activities,
|
|
options,
|
|
};
|
|
}
|
|
|
|
async function fetchOptionGroups(ids: number[]): Promise<Record<number, SelectOption[]>> {
|
|
if (ids.length === 0) return {};
|
|
const res = await civi<{
|
|
value: string;
|
|
label: string;
|
|
option_group_id: number;
|
|
is_active: boolean;
|
|
}>("OptionValue", "get", {
|
|
select: ["value", "label", "option_group_id", "is_active"],
|
|
where: [
|
|
["option_group_id", "IN", ids],
|
|
["is_active", "=", true],
|
|
],
|
|
orderBy: { weight: "ASC" },
|
|
limit: 1000,
|
|
});
|
|
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;
|
|
}
|