The Engagement Report tab wrote STAFF_REPORT_KEY into the iframe src for every user with "access CiviCRM", without checking contact permissions. Anyone could copy it from the DOM and read any org's report and files, bypassing Civi ACLs; the key never expired and landed in access logs. - Extension (0.4.0): Tab.php checks Contact_Permission::allow(VIEW) and mints sig = base64url(HMAC-SHA256(key, "webform-mw.staff-report.v1: <org>:<exp>")) with an 8h exp. The secret no longer leaves the server. - App: lib/staff-token.mjs verifies the token (constant-time, org must match, rejects expired or >12h tokens). /staff/report, /api/staff/report and /api/staff/file share staffAuthQuery(); client links carry exp/sig. - Legacy ?key= is still accepted (constant-time compare, logs a warning) so the app can deploy before the extension. Remove it once every Civi install runs extension 0.4.0. - PHP/Node signature compatibility is pinned by a PHP-generated vector.
542 lines
18 KiB
TypeScript
542 lines
18 KiB
TypeScript
/**
|
|
* GET /api/staff/report?org=<id>&exp=<unix>&sig=<hmac>
|
|
*
|
|
* Returns a comprehensive read-only StaffReportPayload for the given
|
|
* organization. Field list is built at request time from
|
|
* CustomField.get (no static config).
|
|
*
|
|
* Auth: a signed org-bound token (or the transitional legacy `key`); see
|
|
* lib/staff-auth.ts.
|
|
*
|
|
* 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 { staffAuthQuery } 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 orgStr = url.searchParams.get("org");
|
|
|
|
if (
|
|
!staffAuthQuery({
|
|
org: orgStr,
|
|
exp: url.searchParams.get("exp"),
|
|
sig: url.searchParams.get("sig"),
|
|
key: url.searchParams.get("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;
|
|
}
|