424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
/**
|
|
* GET /api/health
|
|
*
|
|
* Diagnostic endpoint that probes the configured CiviCRM instance and
|
|
* reports back exactly which capability checks pass or fail. Useful for:
|
|
* - confirming env vars are loaded
|
|
* - confirming auth headers are accepted
|
|
* - confirming the expected entities (relationship type, activity type,
|
|
* stage option group) exist with the names this app expects
|
|
* - confirming Contact.validateChecksum is callable (so the auth path
|
|
* in /api/data won't fail)
|
|
*
|
|
* Returns a JSON object with one entry per check: `{ check, ok, detail }`.
|
|
* No secrets are echoed back — only the base URL and presence-of-key
|
|
* booleans.
|
|
*
|
|
* Safe to expose. If you want to lock it down later, gate behind
|
|
* `process.env.NODE_ENV !== "production"` or an admin token check.
|
|
*/
|
|
|
|
import { NextResponse } from "next/server";
|
|
import { civi, verifyChecksum } from "@/lib/civicrm";
|
|
import { ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
|
|
|
|
interface CheckResult {
|
|
check: string;
|
|
ok: boolean;
|
|
detail: string;
|
|
}
|
|
|
|
function envSummary(): CheckResult {
|
|
const have = {
|
|
CIVI_BASE_URL: Boolean(process.env.CIVI_BASE_URL),
|
|
CIVI_API_KEY: Boolean(process.env.CIVI_API_KEY),
|
|
CIVI_SITE_KEY: Boolean(process.env.CIVI_SITE_KEY),
|
|
};
|
|
const ok = have.CIVI_BASE_URL && have.CIVI_API_KEY && have.CIVI_SITE_KEY;
|
|
const baseUrl = process.env.CIVI_BASE_URL ?? "(unset)";
|
|
const basicAuth =
|
|
process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS
|
|
? `set (${process.env.CIVI_HTTP_AUTH_USER})`
|
|
: "not set";
|
|
const detail = ok
|
|
? `Civi env vars present. CIVI_BASE_URL=${baseUrl}. HTTP Basic Auth: ${basicAuth}.`
|
|
: `Missing: ${Object.entries(have).filter(([, v]) => !v).map(([k]) => k).join(", ") || "(none)"}. STUB MODE active.`;
|
|
return { check: "env_vars", ok, detail };
|
|
}
|
|
|
|
async function probeWebserverReachable(): Promise<CheckResult> {
|
|
// Detects the "Apache 401 in front of Civi" pattern specifically. We GET
|
|
// the base URL with the same auth headers we'd send to APIv4. If the
|
|
// webserver itself rejects (401 + HTML body), it's an htaccess basic-auth
|
|
// wall and we need CIVI_HTTP_AUTH_USER / CIVI_HTTP_AUTH_PASS.
|
|
try {
|
|
const headers: Record<string, string> = {};
|
|
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
|
|
const creds = Buffer.from(
|
|
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
|
|
).toString("base64");
|
|
headers["Authorization"] = `Basic ${creds}`;
|
|
}
|
|
const res = await fetch(`${process.env.CIVI_BASE_URL}/civicrm`, {
|
|
method: "GET",
|
|
headers,
|
|
cache: "no-store",
|
|
redirect: "manual",
|
|
});
|
|
if (res.status === 401) {
|
|
const auth = res.headers.get("www-authenticate") ?? "";
|
|
return {
|
|
check: "webserver_reachable",
|
|
ok: false,
|
|
detail:
|
|
`Webserver returned 401 from ${process.env.CIVI_BASE_URL}/civicrm. ` +
|
|
`WWW-Authenticate: "${auth}". ` +
|
|
`This is an HTTP Basic Auth wall in front of CiviCRM (Apache/htaccess). ` +
|
|
`Set CIVI_HTTP_AUTH_USER and CIVI_HTTP_AUTH_PASS in .env.local.`,
|
|
};
|
|
}
|
|
return {
|
|
check: "webserver_reachable",
|
|
ok: res.status < 500,
|
|
detail: `GET /civicrm → HTTP ${res.status}.`,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
check: "webserver_reachable",
|
|
ok: false,
|
|
detail: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
}
|
|
|
|
async function probeContactGet(): Promise<CheckResult> {
|
|
try {
|
|
const res = await civi("Contact", "get", {
|
|
select: ["id"],
|
|
where: [["id", "=", 1]],
|
|
limit: 1,
|
|
});
|
|
return {
|
|
check: "civi_auth_and_contact_get",
|
|
ok: true,
|
|
detail: `Contact.get succeeded. Returned ${(res.values ?? []).length} row(s).`,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
check: "civi_auth_and_contact_get",
|
|
ok: false,
|
|
detail: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
}
|
|
|
|
async function probeRelationshipType(): Promise<CheckResult> {
|
|
try {
|
|
const exact = await civi<{
|
|
id: number;
|
|
name_a_b: string;
|
|
name_b_a: string;
|
|
label_a_b: string;
|
|
contact_type_a: string;
|
|
contact_type_b: string;
|
|
}>("RelationshipType", "get", {
|
|
select: ["id", "name_a_b", "name_b_a", "label_a_b", "contact_type_a", "contact_type_b"],
|
|
where: [["name_a_b", "=", FORM_CONTACT_RELATIONSHIP]],
|
|
});
|
|
const rows = exact.values ?? [];
|
|
if (rows.length === 1) {
|
|
const r = rows[0];
|
|
const directionOk = r.contact_type_a === "Individual" && r.contact_type_b === "Organization";
|
|
return {
|
|
check: `relationship_type_${FORM_CONTACT_RELATIONSHIP.replace(/\W+/g, "_").toLowerCase()}`,
|
|
ok: directionOk,
|
|
detail: directionOk
|
|
? `Found id=${r.id}, name_a_b="${r.name_a_b}", direction Individual → Organization. ✓`
|
|
: `Found id=${r.id} but direction is ${r.contact_type_a} → ${r.contact_type_b} (expected Individual → Organization).`,
|
|
};
|
|
}
|
|
return {
|
|
check: `relationship_type_${FORM_CONTACT_RELATIONSHIP.replace(/\W+/g, "_").toLowerCase()}`,
|
|
ok: false,
|
|
detail:
|
|
rows.length === 0
|
|
? `No relationship type with name_a_b="${FORM_CONTACT_RELATIONSHIP}" found.`
|
|
: `Multiple relationship types named "${FORM_CONTACT_RELATIONSHIP}" (${rows.length}). Should be exactly one.`,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
check: `relationship_type_${FORM_CONTACT_RELATIONSHIP.replace(/\W+/g, "_").toLowerCase()}`,
|
|
ok: false,
|
|
detail: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
}
|
|
|
|
async function probeActivityType(): Promise<CheckResult> {
|
|
try {
|
|
const res = await civi<{ value: string; name: string; label: string }>(
|
|
"OptionValue",
|
|
"get",
|
|
{
|
|
select: ["value", "name", "label"],
|
|
where: [
|
|
["option_group_id:name", "=", "activity_type"],
|
|
["name", "=", ACTIVITY_TYPE_NAME],
|
|
],
|
|
},
|
|
);
|
|
const rows = res.values ?? [];
|
|
return {
|
|
check: "activity_type_check_in_organizing",
|
|
ok: rows.length === 1,
|
|
detail:
|
|
rows.length === 1
|
|
? `Found value=${rows[0].value}, name="${rows[0].name}".`
|
|
: `Expected exactly one activity type named "${ACTIVITY_TYPE_NAME}", got ${rows.length}.`,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
check: "activity_type_check_in_organizing",
|
|
ok: false,
|
|
detail: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
}
|
|
|
|
async function probeStageOptionGroup(): Promise<CheckResult> {
|
|
// Resolve the option group via the Framework Stage field's metadata
|
|
// (custom field id=1 on Organization, in Food_Co_op_Organizing group).
|
|
// This is more robust than guessing the option group's machine name.
|
|
try {
|
|
const fieldRes = await civi<{ option_group_id: number; name: string }>("CustomField", "get", {
|
|
select: ["option_group_id", "name"],
|
|
where: [["id", "=", 1]],
|
|
});
|
|
const fieldRow = fieldRes.values?.[0];
|
|
if (!fieldRow?.option_group_id) {
|
|
return {
|
|
check: "stage_option_group_values",
|
|
ok: false,
|
|
detail: `CustomField id=1 (Framework Stage) not found, or has no option_group_id.`,
|
|
};
|
|
}
|
|
const ogId = fieldRow.option_group_id;
|
|
const valuesRes = await civi<{ value: string; label: string }>("OptionValue", "get", {
|
|
select: ["value", "label"],
|
|
where: [
|
|
["option_group_id", "=", ogId],
|
|
["is_active", "=", true],
|
|
],
|
|
orderBy: { weight: "ASC" },
|
|
limit: 100,
|
|
});
|
|
const rows = valuesRes.values ?? [];
|
|
const expected = [
|
|
"Inquiry",
|
|
"Organizing",
|
|
"Feasibility",
|
|
"Business feasibility",
|
|
"Store Implementation",
|
|
"Stabilize newly opened co-op",
|
|
];
|
|
const present = new Set(rows.map((r) => r.value));
|
|
const missing = expected.filter((v) => !present.has(v));
|
|
const sample = rows.slice(0, 10).map((r) => `${r.value}`).join(", ");
|
|
return {
|
|
check: "stage_option_group_values",
|
|
ok: missing.length === 0,
|
|
detail:
|
|
missing.length === 0
|
|
? `All 6 in-scope stage values present in option_group_id=${ogId}. Total active options: ${rows.length}.`
|
|
: `option_group_id=${ogId} has ${rows.length} active option(s) but missing expected: ${missing.join(", ")}. First few present values: ${sample || "(none)"}`,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
check: "stage_option_group_values",
|
|
ok: false,
|
|
detail: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
}
|
|
|
|
async function probeChecksumApi(): Promise<CheckResult> {
|
|
// Tries to call validateChecksum with a deliberately invalid checksum.
|
|
// If the API exists, we get back {valid: false} or similar — that's
|
|
// success for THIS check (the API responded). If the API doesn't exist
|
|
// we fall back to a Contact.get probe in verifyChecksum, which will
|
|
// also fail gracefully.
|
|
try {
|
|
await civi("Contact", "validateChecksum", {
|
|
contactId: 1,
|
|
checksum: "invalid-deliberate-test",
|
|
});
|
|
return {
|
|
check: "contact_validate_checksum_api",
|
|
ok: true,
|
|
detail: "Contact.validateChecksum action is callable.",
|
|
};
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
// If the error is "API action not found" we know it's missing.
|
|
// If it's "invalid checksum" or similar, the API IS present and rejecting our test value (that's fine).
|
|
if (/not\s*found|unknown\s*action|does not exist/i.test(msg)) {
|
|
return {
|
|
check: "contact_validate_checksum_api",
|
|
ok: false,
|
|
detail: `validateChecksum action not present in this Civi version. The verifyChecksum() helper falls back to a Contact.get probe with cs param. Detail: ${msg}`,
|
|
};
|
|
}
|
|
return {
|
|
check: "contact_validate_checksum_api",
|
|
ok: true,
|
|
detail: `Endpoint reachable; expected rejection of test value. Detail: ${msg.slice(0, 160)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function probeChecksumWithSampleContact(): Promise<CheckResult> {
|
|
// Optional: lets the user supply ?cid=&cs= to actually verify a real
|
|
// checksum against the configured CiviCRM. Skipped if not provided.
|
|
return {
|
|
check: "checksum_live_test",
|
|
ok: true,
|
|
detail: "Pass ?cid=...&cs=... to /api/health to test a live checksum (skipped — no params).",
|
|
};
|
|
}
|
|
|
|
async function probeContactRelationships(cid: string): Promise<CheckResult> {
|
|
// Dumps every active relationship involving this contact, regardless of
|
|
// direction. Useful when /api/data returns 404 — tells you whether the
|
|
// relationship exists at all and which side this contact is on.
|
|
try {
|
|
const res = await civi<{
|
|
id: number;
|
|
contact_id_a: number;
|
|
contact_id_b: number;
|
|
"relationship_type_id:name_a_b": string;
|
|
"relationship_type_id:name_b_a": string;
|
|
is_active: boolean;
|
|
}>("Relationship", "get", {
|
|
select: [
|
|
"id",
|
|
"contact_id_a",
|
|
"contact_id_b",
|
|
"relationship_type_id:name_a_b",
|
|
"relationship_type_id:name_b_a",
|
|
"is_active",
|
|
],
|
|
where: [
|
|
["OR", [["contact_id_a", "=", Number(cid)], ["contact_id_b", "=", Number(cid)]]],
|
|
["is_active", "=", true],
|
|
],
|
|
limit: 50,
|
|
});
|
|
const rows = res.values ?? [];
|
|
if (rows.length === 0) {
|
|
return {
|
|
check: "contact_relationships_dump",
|
|
ok: false,
|
|
detail: `Contact ${cid} has no active relationships at all. Add a relationship of type "${FORM_CONTACT_RELATIONSHIP}" with this contact on side A (Individual) and the target Organization on side B.`,
|
|
};
|
|
}
|
|
const lines = rows.map((r) => {
|
|
const sideA = String(r.contact_id_a) === cid ? "★A" : "·A";
|
|
const sideB = String(r.contact_id_b) === cid ? "★B" : "·B";
|
|
return ` ${sideA}=${r.contact_id_a} ${sideB}=${r.contact_id_b} type="${r["relationship_type_id:name_a_b"]}" / inverse="${r["relationship_type_id:name_b_a"]}"`;
|
|
});
|
|
const usable = rows.find(
|
|
(r) =>
|
|
String(r.contact_id_a) === cid &&
|
|
r["relationship_type_id:name_a_b"] === FORM_CONTACT_RELATIONSHIP,
|
|
);
|
|
return {
|
|
check: "contact_relationships_dump",
|
|
ok: Boolean(usable),
|
|
detail:
|
|
`Active relationships involving contact ${cid} (★ marks the side this contact is on):\n` +
|
|
lines.join("\n") +
|
|
"\n\n" +
|
|
(usable
|
|
? `✓ Found a usable "${FORM_CONTACT_RELATIONSHIP}" with contact ${cid} on side A (id=${usable.id}, org=${usable.contact_id_b}).`
|
|
: `✗ No relationship matches: type="${FORM_CONTACT_RELATIONSHIP}" with contact ${cid} on side A. ` +
|
|
`If you see the right relationship above with contact ${cid} on side B, the relationship is stored backwards — delete and re-add with the Individual chosen first, OR change FORM_CONTACT_RELATIONSHIP in config/form.ts to the inverse name (name_b_a).`),
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
check: "contact_relationships_dump",
|
|
ok: false,
|
|
detail: e instanceof Error ? e.message : String(e),
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function GET(req: Request) {
|
|
const env = envSummary();
|
|
const summary = { mode: env.ok ? "live" : "stub" };
|
|
|
|
if (!env.ok) {
|
|
// No live calls in stub mode — return early.
|
|
return NextResponse.json({ ...summary, checks: [env] });
|
|
}
|
|
|
|
const url = new URL(req.url);
|
|
const cid = url.searchParams.get("cid");
|
|
const cs = url.searchParams.get("cs");
|
|
|
|
const checks: CheckResult[] = [env];
|
|
|
|
// Webserver probe first — if this fails, the others will too. We run it
|
|
// serially so we get a clean "fix this first" signal at the top.
|
|
const webserver = await probeWebserverReachable();
|
|
checks.push(webserver);
|
|
|
|
if (webserver.ok) {
|
|
const probes = await Promise.all([
|
|
probeContactGet(),
|
|
probeRelationshipType(),
|
|
probeActivityType(),
|
|
probeStageOptionGroup(),
|
|
probeChecksumApi(),
|
|
]);
|
|
checks.push(...probes);
|
|
} else {
|
|
checks.push({
|
|
check: "civi_probes_skipped",
|
|
ok: false,
|
|
detail: "Skipped Civi-level probes because the webserver itself rejected the request. Fix webserver_reachable above first.",
|
|
});
|
|
}
|
|
|
|
// If a sample cid+cs was provided, verify that specific checksum + dump
|
|
// the contact's relationships.
|
|
if (cid) {
|
|
checks.push(await probeContactRelationships(cid));
|
|
}
|
|
if (cid && cs) {
|
|
try {
|
|
const ok = await verifyChecksum(cid, cs);
|
|
checks.push({
|
|
check: "checksum_live_test",
|
|
ok,
|
|
detail: ok
|
|
? `verifyChecksum(cid=${cid}, cs=...) returned true.`
|
|
: `verifyChecksum(cid=${cid}, cs=...) returned false. Either the checksum is invalid or the verifier path failed silently.`,
|
|
});
|
|
} catch (e) {
|
|
checks.push({
|
|
check: "checksum_live_test",
|
|
ok: false,
|
|
detail: e instanceof Error ? e.message : String(e),
|
|
});
|
|
}
|
|
} else if (!cid) {
|
|
checks.push(await probeChecksumWithSampleContact());
|
|
}
|
|
|
|
const allOk = checks.every((c) => c.ok);
|
|
return NextResponse.json(
|
|
{ ...summary, allOk, checks },
|
|
{ status: allOk ? 200 : 500 },
|
|
);
|
|
}
|