Files
WebForm-mw/lib/civicrm.ts

119 lines
4.1 KiB
TypeScript

/**
* 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 <base64>` 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<T = unknown> {
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<T = unknown>(
entity: string,
action: string,
params: Record<string, unknown>,
opts: CiviApiOptions = {},
): Promise<CiviApiResponse<T>> {
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<string, string> = {
"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<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<boolean> {
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;
}
}