Staff report: live Civi branch (schema discovery + activity walk)
This commit is contained in:
@@ -13,7 +13,44 @@
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isStaffKeyValid } from "@/lib/staff-auth";
|
||||
import type { StaffReportPayload } from "@/types/form";
|
||||
import { civi } 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 !(
|
||||
@@ -206,6 +243,207 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json(buildStubPayload(orgId));
|
||||
}
|
||||
|
||||
// Live Civi branch added in Task 8.
|
||||
return NextResponse.json({ error: "Live mode not yet implemented." }, { status: 501 });
|
||||
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).
|
||||
const orgSelect = ["id", "display_name", "contact_type", ...orgDescriptors.map((d) => d.civiField)];
|
||||
// 3. Activities (every activity-side custom field + file-name/url joins).
|
||||
const fileFieldRefs = activityDescriptors
|
||||
.filter((d) => d.render === "file")
|
||||
.flatMap((d) => [`${d.civiField}.file_name`, `${d.civiField}.url`]);
|
||||
const activitySelect = [
|
||||
"id",
|
||||
"activity_date_time",
|
||||
"subject",
|
||||
"source_contact_id.display_name",
|
||||
ACTIVITY_STAGE_FIELD,
|
||||
...activityDescriptors.map((d) => d.civiField),
|
||||
...fileFieldRefs,
|
||||
];
|
||||
// 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 ?? [];
|
||||
|
||||
// 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];
|
||||
const history: FieldHistoryEntry[] =
|
||||
raw === null || raw === undefined || raw === ""
|
||||
? []
|
||||
: [{ activityId: 0, date: "", value: raw }];
|
||||
return { descriptor: d, history };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// 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 url = row[`${d.civiField}.url`];
|
||||
value = {
|
||||
id: v,
|
||||
file_name: typeof fname === "string" ? fname : undefined,
|
||||
url: typeof url === "string" ? url : 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user