From 234bec5766a699a8f7cf913ccac08209aeb71fee Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Sat, 9 May 2026 20:45:14 -0700 Subject: [PATCH] Add /api/health diagnostic route; remove stale .js route duplicates --- app/api/data/route.js | 60 --------- app/api/health/route.ts | 263 ++++++++++++++++++++++++++++++++++++++++ app/api/submit/route.js | 55 --------- 3 files changed, 263 insertions(+), 115 deletions(-) delete mode 100644 app/api/data/route.js create mode 100644 app/api/health/route.ts delete mode 100644 app/api/submit/route.js diff --git a/app/api/data/route.js b/app/api/data/route.js deleted file mode 100644 index 323ab0f..0000000 --- a/app/api/data/route.js +++ /dev/null @@ -1,60 +0,0 @@ -import { NextResponse } from 'next/server'; -import { civicrmApi } from '@/lib/civicrm'; -import mapping from '@/config/mapping.json'; - -export async function GET(request) { - const { searchParams } = new URL(request.url); - const orgId = searchParams.get('orgId'); - const contactId = searchParams.get('contactId'); - - if (!orgId || !contactId) { - return NextResponse.json({ error: 'Missing orgId or contactId' }, { status: 400 }); - } - - try { - // 1. Fetch current stage from Organization (Contact record with type Organization) - const orgResult = await civicrmApi('Contact', 'get', { - select: [mapping.stageField], - where: [['id', '=', parseInt(orgId)]], - }); - - const currentStage = orgResult.values[0]?.[mapping.stageField] || 0; - - // 2. Prepare select fields for pre-filling contact data - const selectFields = ['id']; - mapping.stages.forEach(stage => { - stage.fields.forEach(field => { - if (field.entity === 'Contact') { - selectFields.push(field.crmField); - } - }); - }); - - // 3. Fetch contact data for pre-filling - const contactResult = await civicrmApi('Contact', 'get', { - select: selectFields, - where: [['id', '=', parseInt(contactId)]], - }); - - const contactData = contactResult.values[0] || {}; - - return NextResponse.json({ - currentStage: parseInt(currentStage), - prefillData: contactData, - }); - } catch (error) { - console.error('API Error:', error); - // Return mock data if CiviCRM is not reachable (for development/demo) - if (process.env.NODE_ENV === 'development') { - return NextResponse.json({ - currentStage: 2, - prefillData: { - first_name: 'John', - last_name: 'Doe', - 'email_primary.email': 'john@example.com' - } - }); - } - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); - } -} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..6192e04 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,263 @@ +/** + * 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 detail = ok + ? `All three env vars present. CIVI_BASE_URL=${baseUrl}` + : `Missing: ${Object.entries(have).filter(([, v]) => !v).map(([k]) => k).join(", ") || "(none)"}. STUB MODE active.`; + return { check: "env_vars", ok, detail }; +} + +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 res = await civi<{ id: number; name_a_b: string; name_b_a: string }>( + "RelationshipType", + "get", + { + select: ["id", "name_a_b", "name_b_a"], + where: [["name_a_b", "=", "Primary Form Contact of"]], + }, + ); + const rows = res.values ?? []; + return { + check: "relationship_type_primary_form_contact_of", + ok: rows.length === 1, + detail: + rows.length === 1 + ? `Found id=${rows[0].id}, name_a_b="${rows[0].name_a_b}", name_b_a="${rows[0].name_b_a}".` + : rows.length === 0 + ? `Not found. Create a relationship type named "Primary Form Contact of" (Individual ↔ Organization).` + : `Multiple matches (${rows.length}). Should be exactly one.`, + }; + } catch (e) { + return { + check: "relationship_type_primary_form_contact_of", + 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 { + try { + const res = await civi<{ value: string; label: string }>("OptionValue", "get", { + select: ["value", "label"], + where: [ + ["option_group_id:name", "=", "Stage"], + ["is_active", "=", true], + ], + orderBy: { weight: "ASC" }, + limit: 50, + }); + const rows = res.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)); + return { + check: "stage_option_group_values", + ok: missing.length === 0, + detail: + missing.length === 0 + ? `All 6 in-scope stage values present (${expected.join(", ")}). Total active options: ${rows.length}.` + : `Missing expected stage value(s): ${missing.join(", ")}. Found ${rows.length} active options.`, + }; + } 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).", + }; +} + +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]; + + // Run independent probes in parallel. + const probes = await Promise.all([ + probeContactGet(), + probeRelationshipType(), + probeActivityType(), + probeStageOptionGroup(), + probeChecksumApi(), + ]); + checks.push(...probes); + + // 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 }, + ); +} diff --git a/app/api/submit/route.js b/app/api/submit/route.js deleted file mode 100644 index c0df6b5..0000000 --- a/app/api/submit/route.js +++ /dev/null @@ -1,55 +0,0 @@ -import { NextResponse } from 'next/server'; -import { civicrmApi } from '@/lib/civicrm'; -import mapping from '@/config/mapping.json'; - -export async function POST(request) { - try { - const body = await request.json(); - const { contactId, orgId, formData } = body; - - if (!contactId || !orgId || !formData) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); - } - - // 1. Separate fields by entity for updating - const contactUpdates = {}; - - mapping.stages.forEach(stage => { - stage.fields.forEach(field => { - if (formData[field.name] !== undefined) { - if (field.entity === 'Contact') { - contactUpdates[field.crmField] = formData[field.name]; - } - } - }); - }); - - // 2. Update Contact in CiviCRM - if (Object.keys(contactUpdates).length > 0) { - await civicrmApi('Contact', 'save', { - records: [{ id: parseInt(contactId), ...contactUpdates }], - }); - } - - // 3. Create Activity in CiviCRM to record the submission - await civicrmApi('Activity', 'create', { - values: { - activity_type_id: 'Meeting', // Or a custom "Form Submission" activity type - subject: `Stage Progression Form Submission (Org ID: ${orgId})`, - target_contact_id: [parseInt(contactId)], - source_contact_id: parseInt(contactId), // Assuming contact submits for themselves or adjust - status_id: 'Completed', - details: JSON.stringify(formData, null, 2), - }, - }); - - return NextResponse.json({ success: true, message: 'Data saved and activity created' }); - } catch (error) { - console.error('Submission Error:', error); - // Mock success for development - if (process.env.NODE_ENV === 'development') { - return NextResponse.json({ success: true, message: 'Mock Success' }); - } - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); - } -}