Files
WebForm-mw/app/api/health/route.ts

373 lines
13 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 } 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 }>(
"RelationshipType",
"get",
{
select: ["id", "name_a_b", "name_b_a", "label_a_b"],
where: [["name_a_b", "=", "Primary Form Contact of"]],
},
);
if ((exact.values ?? []).length === 1) {
const r = exact.values[0];
return {
check: "relationship_type_primary_form_contact_of",
ok: true,
detail: `Found id=${r.id}, name_a_b="${r.name_a_b}", name_b_a="${r.name_b_a}".`,
};
}
// Not found by exact name — list close matches so we know what's available.
const candidates = await civi<{ id: number; name_a_b: string; label_a_b: string; contact_type_a: string; contact_type_b: string }>(
"RelationshipType",
"get",
{
select: ["id", "name_a_b", "label_a_b", "contact_type_a", "contact_type_b"],
where: [
[
"OR",
[
["name_a_b", "LIKE", "%Form%"],
["name_a_b", "LIKE", "%Primary%"],
["name_a_b", "LIKE", "%Contact%"],
["label_a_b", "LIKE", "%Form%"],
["label_a_b", "LIKE", "%Primary%"],
],
],
],
limit: 25,
},
);
const closeMatches = (candidates.values ?? [])
.map((r) => ` • id=${r.id} name_a_b="${r.name_a_b}" label="${r.label_a_b}" (${r.contact_type_a}${r.contact_type_b})`)
.join("\n");
return {
check: "relationship_type_primary_form_contact_of",
ok: false,
detail:
"No relationship type with name_a_b='Primary Form Contact of' on this site.\n" +
"Existing relationship types matching Form/Primary/Contact:\n" +
(closeMatches || " (none)") +
"\nFix: create a new RelationshipType (Individual A ↔ Organization B) with name_a_b='Primary Form Contact of', or change the expected name in /api/data and /api/submit to match an existing type.",
};
} catch (e) {
return {
check: "relationship_type_primary_form_contact_of",
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).",
};
}
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.
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 {
checks.push(await probeChecksumWithSampleContact());
}
const allOk = checks.every((c) => c.ok);
return NextResponse.json(
{ ...summary, allOk, checks },
{ status: allOk ? 200 : 500 },
);
}