Build standalone CiviCRM check-in middleware
This commit is contained in:
@@ -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
105
lib/civicrm.ts
Normal 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
80
lib/conditional.ts
Normal 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
67
lib/prefill.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user