/** * CiviCRM APIv4 client. * * Reads credentials from environment variables. Falls back to STUB mode if * any required env var is missing — STUB mode returns mock data so the UI * can be developed without a live CiviCRM instance. * * Env vars: * CIVI_BASE_URL e.g. https://crm.fci.coop * CIVI_API_KEY per-user API key (Civi user "API Key" property) * CIVI_SITE_KEY site-wide key (from civicrm.settings.php) * CIVI_HTTP_AUTH_USER (optional) HTTP Basic Auth username, if the site * itself sits behind webserver-level basic auth * (common on staging/dev). When set together with * CIVI_HTTP_AUTH_PASS, every request adds an * `Authorization: Basic ` header. * CIVI_HTTP_AUTH_PASS (optional) HTTP Basic Auth password. * * Auth strategy may need adjustment depending on your CiviCRM auth extension * (AuthX vs stock APIv3-style site_key/api_key). The header style here * matches the AuthX pattern; classic API3 users may need different headers. */ export interface CiviApiOptions { /** Override env CIVI_BASE_URL for one-off calls (e.g. tests). */ baseUrl?: string; } export interface CiviApiResponse { values: T[]; count?: number; } const STUB_LOG_PREFIX = "[civi:STUB]"; function isStubMode(): boolean { return !( process.env.CIVI_BASE_URL && process.env.CIVI_API_KEY && process.env.CIVI_SITE_KEY ); } /** * Generic APIv4 call. `entity` is e.g. "Contact" / "Activity" / "Relationship". * `action` is the APIv4 action name. `params` is the JSON params object. */ export async function civi( entity: string, action: string, params: Record, opts: CiviApiOptions = {}, ): Promise> { if (isStubMode()) { console.warn(`${STUB_LOG_PREFIX} ${entity}.${action} — env not set, returning empty values`); return { values: [] }; } const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!; const url = `${base}/civicrm/ajax/api4/${entity}/${action}`; const body = new URLSearchParams({ params: JSON.stringify(params), }); const headers: Record = { "Content-Type": "application/x-www-form-urlencoded", "X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`, "X-Civi-Key": process.env.CIVI_SITE_KEY!, }; // Webserver-level HTTP Basic Auth (e.g. site is gated by .htaccess on staging). 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(url, { method: "POST", headers, body, cache: "no-store", }); if (!res.ok) { const text = await res.text(); throw new Error(`CiviCRM ${entity}.${action} failed (${res.status}): ${text}`); } return (await res.json()) as CiviApiResponse; } /** * Legacy APIv3 call. Some Civi entities (notably Attachment) are exposed * only via APIv3 on this install; this helper hits the universal * /civicrm/ajax/rest endpoint with AuthX headers. Returns the normalized * values list — APIv3 may return values as either an array or an object * keyed by id, depending on version; we flatten to an array. */ export async function civi3( entity: string, action: string, params: Record, opts: CiviApiOptions = {}, ): Promise> { if (isStubMode()) { console.warn(`${STUB_LOG_PREFIX} v3 ${entity}.${action} — env not set, returning empty values`); return { values: [] }; } const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!; const url = `${base}/civicrm/ajax/rest`; const body = new URLSearchParams({ entity, action, json: JSON.stringify(params), }); const headers: Record = { "Content-Type": "application/x-www-form-urlencoded", "X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`, "X-Civi-Key": process.env.CIVI_SITE_KEY!, // v3's rest endpoint enforces this header as CSRF protection. "X-Requested-With": "XMLHttpRequest", }; 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(url, { method: "POST", headers, body, cache: "no-store", }); if (!res.ok) { const text = await res.text(); throw new Error(`CiviCRM v3 ${entity}.${action} failed (${res.status}): ${text}`); } const json = (await res.json()) as { is_error?: number; error_message?: string; values?: T[] | Record; count?: number; }; if (json.is_error) { throw new Error( `CiviCRM v3 ${entity}.${action} error: ${json.error_message ?? "unknown"}`, ); } const raw = json.values; const values: T[] = Array.isArray(raw) ? raw : raw && typeof raw === "object" ? Object.values(raw) : []; return { values, count: json.count }; } /** * Multipart APIv3 call. Used for binary uploads — APIv4 File.create stores * the `content` field verbatim (no base64 decoding), so files come back * corrupted. APIv3 Attachment.create accepts the file via the standard * multipart `file` part (read from $_FILES on the server) which preserves * the bytes exactly. * * Caller provides `params` (non-binary metadata) and `file` (the binary + * filename + mime). Authentication is the same AuthX headers as civi3(). */ export async function civi3Upload( entity: string, action: string, params: Record, file: { bytes: Uint8Array; filename: string; mime: string }, opts: CiviApiOptions = {}, ): Promise> { if (isStubMode()) { console.warn( `${STUB_LOG_PREFIX} v3-multipart ${entity}.${action} — env not set, returning empty values`, ); return { values: [] }; } const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!; const url = `${base}/civicrm/ajax/rest`; const form = new FormData(); form.append("entity", entity); form.append("action", action); form.append("json", JSON.stringify(params)); // Wrap the Uint8Array in a fresh ArrayBuffer slice so Blob's typing // (which only accepts ArrayBuffer, not the wider ArrayBufferLike) is // happy. The slice is a no-op on real Uint8Array inputs. const fileBuf = file.bytes.buffer.slice( file.bytes.byteOffset, file.bytes.byteOffset + file.bytes.byteLength, ) as ArrayBuffer; form.append( "file", new Blob([fileBuf], { type: file.mime }), file.filename, ); // Do NOT set Content-Type — fetch sets multipart/form-data with the // correct boundary automatically when body is a FormData. const headers: Record = { "X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`, "X-Civi-Key": process.env.CIVI_SITE_KEY!, "X-Requested-With": "XMLHttpRequest", }; 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(url, { method: "POST", headers, body: form, cache: "no-store", }); if (!res.ok) { const text = await res.text(); throw new Error( `CiviCRM v3 ${entity}.${action} (multipart) failed (${res.status}): ${text}`, ); } const json = (await res.json()) as { is_error?: number; error_message?: string; values?: T[] | Record; count?: number; }; if (json.is_error) { throw new Error( `CiviCRM v3 ${entity}.${action} (multipart) error: ${json.error_message ?? "unknown"}`, ); } const raw = json.values; const values: T[] = Array.isArray(raw) ? raw : raw && typeof raw === "object" ? Object.values(raw) : []; return { values, count: json.count }; } /** * POST a multipart request directly to an arbitrary Civi route. Used for * extension endpoints that handle multipart uploads natively (the v3/v4 * ajax/rest path silently drops $_FILES on this install, and JSON+base64 * stores the literal base64 text on disk). * * The caller's `path` is appended to CIVI_BASE_URL. AuthX headers are sent * the same way as the other helpers. Returns the parsed JSON body. */ export async function civiMultipart( path: string, fields: Record, file: { bytes: Uint8Array; filename: string; mime: string }, opts: CiviApiOptions = {}, ): Promise { if (isStubMode()) { console.warn(`${STUB_LOG_PREFIX} multipart ${path} — env not set`); return {} as T; } const base = opts.baseUrl ?? process.env.CIVI_BASE_URL!; const url = `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`; const form = new FormData(); for (const [k, v] of Object.entries(fields)) form.append(k, v); const fileBuf = file.bytes.buffer.slice( file.bytes.byteOffset, file.bytes.byteOffset + file.bytes.byteLength, ) as ArrayBuffer; form.append("file", new Blob([fileBuf], { type: file.mime }), file.filename); const headers: Record = { "X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`, "X-Civi-Key": process.env.CIVI_SITE_KEY!, "X-Requested-With": "XMLHttpRequest", }; 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(url, { method: "POST", headers, body: form, cache: "no-store", }); if (!res.ok) { const text = await res.text(); throw new Error(`Civi POST ${path} failed (${res.status}): ${text}`); } return (await res.json()) as T; } /** * Validate a contact checksum (cid + cs) against CiviCRM. * * APIv4 exposes Contact.validateChecksum in newer Civi versions. For older * versions you may need to call Contact.get with the cs param and verify * the contact resolves. We use validateChecksum here and fall back to a * Contact.get probe if it returns a "missing API" error. */ export async function verifyChecksum(cid: string, cs: string): Promise { if (isStubMode()) { // STUB: any non-empty cs is "valid" so the UI can be exercised locally. return Boolean(cid && cs); } try { const res = await civi<{ valid: boolean }>("Contact", "validateChecksum", { contactId: Number(cid), checksum: cs, }); return Boolean(res.values?.[0]?.valid); } catch (e) { // Fallback: try Contact.get with the checksum as `cs` URL param. If the // contact resolves, the checksum is valid. const res = await civi<{ id: number }>("Contact", "get", { where: [["id", "=", Number(cid)]], select: ["id"], checksum: cs, }); return Array.isArray(res.values) && res.values.length === 1; } }