106 lines
3.4 KiB
TypeScript
106 lines
3.4 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)
|
|
*
|
|
* If you are seeing STUB MODE warnings, set those three. Auth strategy may
|
|
* also need adjustment depending on your CiviCRM auth extension (AuthX vs
|
|
* stock APIv3-style site_key/api_key). The header style here matches the
|
|
* stock CiviCRM 5+ pattern; AuthX users may need Bearer tokens instead.
|
|
*/
|
|
|
|
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 res = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
|
|
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
|
|
},
|
|
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;
|
|
}
|
|
}
|