Build standalone CiviCRM check-in middleware

This commit is contained in:
Joel Brock
2026-05-09 20:08:15 -07:00
parent 0899e6ae9a
commit 54555c74d2
21 changed files with 1894 additions and 457 deletions

View File

@@ -1,50 +0,0 @@
import axios from 'axios';
const CIVICRM_SITE_URL = process.env.CIVICRM_SITE_URL;
const CIVICRM_API_KEY = process.env.CIVICRM_API_KEY;
const CIVICRM_SITE_KEY = process.env.CIVICRM_SITE_KEY;
const civicrmClient = axios.create({
baseURL: `${CIVICRM_SITE_URL}/civicrm/ajax/api4`,
headers: {
'Content-Type': 'application/json',
'X-Civi-Auth': `Bearer ${CIVICRM_API_KEY}`, // Note: Auth strategy might vary (AuthX, etc.)
'X-Civi-Key': CIVICRM_SITE_KEY,
},
});
// Using AuthX style if preferred by the user's environment,
// but APIv4 REST usually uses these or similar headers.
// We'll stick to a standard implementation that can be adjusted.
export async function civicrmApi(entity, action, params = {}) {
try {
const response = await civicrmClient.post(`/${entity}/${action}`, {
params,
});
return response.data;
} catch (error) {
console.error(`CiviCRM API Error (${entity}.${action}):`, error.response?.data || error.message);
throw error;
}
}
export async function getOrganizationStage(orgId) {
const result = await civicrmApi('Contact', 'get', {
select: ['custom_stage_field'], // Replace with actual field name
where: [['id', '=', orgId]],
});
return result.values[0]?.custom_stage_field || 0;
}
export async function updateContact(contactId, values) {
return await civicrmApi('Contact', 'save', {
records: [{ id: contactId, ...values }],
});
}
export async function createActivity(params) {
return await civicrmApi('Activity', 'create', {
values: params,
});
}

105
lib/civicrm.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* 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;
}
}

80
lib/conditional.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* Visibility engine for form sections and fields.
*
* Evaluates a VisibilityRule tree against the current form state.
* Form state is a plain object {fieldName: value}. Missing fields are
* treated as undefined.
*
* Rule semantics:
* - SimpleCondition: tests a single field with an operator
* - {all: [...]}: AND
* - {any: [...]}: OR
*
* Operator semantics:
* - equals : field value === rule.value
* - notEquals : field value !== rule.value
* - in : rule.values includes the field value
* - notIn : rule.values does not include the field value
* - isEmpty : field is undefined / null / "" / [] / {}
* - isNotEmpty : negation of isEmpty
*
* If a rule is malformed, evaluate returns false (field stays hidden) rather
* than throwing, so a config typo can never break the page.
*/
import type {
VisibilityRule,
SimpleCondition,
AllCondition,
AnyCondition,
} from "@/types/form";
type FormState = Record<string, unknown>;
function isSimple(r: VisibilityRule): r is SimpleCondition {
return typeof (r as SimpleCondition).field === "string";
}
function isAll(r: VisibilityRule): r is AllCondition {
return Array.isArray((r as AllCondition).all);
}
function isAny(r: VisibilityRule): r is AnyCondition {
return Array.isArray((r as AnyCondition).any);
}
function isEmpty(v: unknown): boolean {
if (v === undefined || v === null) return true;
if (typeof v === "string") return v.length === 0;
if (Array.isArray(v)) return v.length === 0;
if (typeof v === "object") return Object.keys(v as object).length === 0;
return false;
}
function evalSimple(c: SimpleCondition, state: FormState): boolean {
const v = state[c.field];
switch (c.op) {
case "equals":
return v === c.value;
case "notEquals":
return v !== c.value;
case "in":
return Array.isArray(c.values) && c.values.includes(v as string | number | boolean);
case "notIn":
return Array.isArray(c.values) && !c.values.includes(v as string | number | boolean);
case "isEmpty":
return isEmpty(v);
case "isNotEmpty":
return !isEmpty(v);
default:
return false;
}
}
export function evaluate(rule: VisibilityRule | undefined, state: FormState): boolean {
if (!rule) return true;
if (isSimple(rule)) return evalSimple(rule, state);
if (isAll(rule)) return rule.all.every((r) => evaluate(r, state));
if (isAny(rule)) return rule.any.some((r) => evaluate(r, state));
return false;
}

67
lib/prefill.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Per-field-most-recent prefill walk.
*
* Loads all `Org Engagement Submission` activities for an organization,
* sorted DESC by activity_date_time. For each field name we care about,
* walk the list and take the first non-null value found.
*
* This closes the v1 prefill gap that Webform CiviCRM cannot express in
* its admin UI: load latest values per-field across multiple activities,
* without coupling to update-mode.
*/
import { civi } from "./civicrm";
import type { FieldConfig } from "@/types/form";
interface ActivityRow {
id: number;
activity_date_time: string;
// CiviCRM returns custom fields under their machine names like
// `custom_42` or `Stage_0_Core.peer_group_participation`.
[key: string]: unknown;
}
export interface PrefillResult {
/** Form-side keys → most-recent non-null value. */
values: Record<string, unknown>;
/** Number of submission activities walked. */
activityCount: number;
}
/**
* Walk Org Engagement Submission activities for the org, returning per-field
* most-recent values keyed by FieldConfig.name. Only fields with a `civiField`
* are looked up; transient fields are ignored.
*/
export async function loadPrefill(
orgId: number,
fields: FieldConfig[],
activityTypeName = "Org Engagement Submission",
): Promise<PrefillResult> {
const civiSelected = fields.filter((f) => f.civiField);
const select = ["id", "activity_date_time", ...new Set(civiSelected.map((f) => f.civiField!))];
const res = await civi<ActivityRow>("Activity", "get", {
select,
where: [
["activity_type_id:name", "=", activityTypeName],
["target_contact_id", "=", orgId],
],
orderBy: { activity_date_time: "DESC" },
limit: 200,
});
const rows = res.values ?? [];
const out: Record<string, unknown> = {};
for (const f of civiSelected) {
for (const row of rows) {
const v = row[f.civiField!];
if (v !== null && v !== undefined && v !== "") {
out[f.name] = v;
break;
}
}
}
return { values: out, activityCount: rows.length };
}