/** * 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"; import { appEnv } from "@/lib/env"; 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 { // 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 = {}; 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 { 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 { 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 { 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 { // 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 { // 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 { // 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 { // 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) { // In production: require ?token=. If HEALTH_TOKEN is unset // in production, the endpoint is disabled entirely (404). In development // / non-prod, anyone can read it. const e = appEnv(); const url = new URL(req.url); if (e.isProduction) { if (!e.healthToken) { return new NextResponse("Not Found", { status: 404 }); } if (url.searchParams.get("token") !== e.healthToken) { return new NextResponse("Not Found", { status: 404 }); } } 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 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 }, ); }