Files
WebForm-mw/scripts/list-civi-entities.mjs
T
Joel Brock 2400931a04 File upload pipeline: wire end-to-end via APIv4 File.create
Closes the file-upload gap. Files now actually land in CiviCRM (verified
empirically against the live Civi instance via spike scripts).

Spike findings (see scripts/spike-file-upload.mjs):
  - APIv4 Attachment is NOT exposed on this Civi
  - APIv4 File + EntityFile ARE exposed; File.create accepts inline
    base64 `content` and returns a usable file id
  - Custom file fields store the file id directly in the custom column,
    so EntityFile linkage is unnecessary for this use case
  - Round-trip via Contact.update + Contact.get .file_name join verified
    on a real org contact

Pipeline:

  Renderer (FileField) picks up onChange  →
    POST /api/upload (multipart) with file + cid + cs + fieldRef  →
      verifyChecksum, MIME allowlist + magic-byte sniff, 5 MB cap  →
        civi.File.create({ file_name, mime_type, content: base64 })  →
          returns { id, file_name }  →
            renderer stores in RHF state via setValue
  Form submit  →
    POST /api/submit (JSON) with the {id, file_name} value  →
      submit detects the file shape and writes the id as the value of
      the activity/contact custom field

File changes:

  app/api/upload/route.ts
    Replaced the 501 stub with the real File.create call. Comment
    documents that EntityFile linkage is intentionally skipped and that
    orphan cleanup is owned by a CiviCRM scheduled job.

  app/api/submit/route.ts
    For type:"file" values shaped as {id, file_name}, write the id as
    the custom field value (activity or contact, depending on the
    civiField / civiContactField the field declares).

  components/fields/FieldRenderer.tsx
    Replaced the bare <input type=file> register() with FileField, an
    upload-on-pick subcomponent. The native input is NOT register()'d:
    its FileList value was the original bug. FileField owns its
    uploading + error state and writes {id, file_name} via setValue on
    success. Submit is blocked upstream while uploads are in flight.

  components/StageSection.tsx, components/EngagementForm.tsx
    Thread setValue, cid, cs, and an onUploadStateChange callback
    through to FieldRenderer. EngagementForm tracks uploads-in-flight
    count; onSubmit refuses to submit while the count is > 0.

  config/form.ts
    Promotes Certificate_of_Incorporation from readonly to a real
    file field now that the pipeline works.

  app/api/data/route.ts
    Drops the readonly carveout that was only needed while the
    certificate was readonly.

  scripts/list-civi-entities.mjs (new)
    APIv4 entity probe + APIv3 attachment-API probe. Used to determine
    that File (not Attachment) was the right entity on this Civi.

  scripts/spike-file-upload.mjs (new)
    The actual end-to-end test that proved out the pipeline before
    wiring. Safe to re-run on any Civi instance during future audits.

Not in this change:
  - Orphan attachment cleanup (CiviCRM scheduled job, Civi admin scope)
  - Per-field MIME allowlists (single global list for v1)
  - S3 / presigned-URL path for >5 MB files (deferred; capped at 5 MB
    today to stay under Amplify Lambda's 6 MB sync payload limit)
2026-06-05 07:48:57 -07:00

118 lines
3.8 KiB
JavaScript

#!/usr/bin/env node
// scripts/list-civi-entities.mjs
//
// Lists what entities APIv4 exposes on this Civi instance, with a focus
// on file/attachment-shaped ones. Run this when Attachment.create comes
// back "API does not exist", to figure out what the real upload path is.
//
// USAGE
// node --env-file=.env.local scripts/list-civi-entities.mjs
import { Buffer } from "node:buffer";
async function civi(entity, action, params) {
const {
CIVI_BASE_URL,
CIVI_API_KEY,
CIVI_SITE_KEY,
CIVI_HTTP_AUTH_USER,
CIVI_HTTP_AUTH_PASS,
} = process.env;
if (!CIVI_BASE_URL || !CIVI_API_KEY || !CIVI_SITE_KEY) {
throw new Error("Missing CIVI_BASE_URL / CIVI_API_KEY / CIVI_SITE_KEY.");
}
const url = `${CIVI_BASE_URL}/civicrm/ajax/api4/${entity}/${action}`;
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
"X-Civi-Auth": `Bearer ${CIVI_API_KEY}`,
"X-Civi-Key": CIVI_SITE_KEY,
};
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
}
const res = await fetch(url, {
method: "POST",
headers,
body: new URLSearchParams({ params: JSON.stringify(params) }),
});
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch {
throw new Error(`${entity}.${action} non-JSON (HTTP ${res.status}): ${text}`);
}
if (!res.ok || json.error_message) {
throw new Error(`${entity}.${action} HTTP ${res.status}: ${json.error_message ?? text}`);
}
return json;
}
console.log("Probing APIv4 entities…\n");
// All registered entities.
const all = await civi("Entity", "get", { select: ["name"], orderBy: { name: "ASC" } });
const names = (all.values ?? []).map((r) => r.name);
console.log(`Total APIv4 entities: ${names.length}\n`);
const fileShaped = names.filter((n) =>
/attach|file|document|upload/i.test(n),
);
console.log("File/attachment-shaped entities present:");
for (const n of fileShaped) console.log(` - ${n}`);
if (fileShaped.length === 0) console.log(" (none)");
console.log("\nFor each, list available actions:");
for (const ent of fileShaped) {
try {
const a = await civi(ent, "getActions", { select: ["name"] });
const actions = (a.values ?? []).map((r) => r.name).join(", ");
console.log(`\n ${ent}: ${actions}`);
} catch (err) {
console.log(`\n ${ent}: <getActions failed: ${err.message}>`);
}
}
// Also probe: does the legacy APIv3 Attachment.create exist? APIv4
// extension surface is different from APIv3, and the form may need to
// fall back to v3 for files. Round-trip a getfields call as a probe.
console.log("\nAPIv3 probe (extern/rest.php):");
try {
const {
CIVI_BASE_URL,
CIVI_API_KEY,
CIVI_SITE_KEY,
CIVI_HTTP_AUTH_USER,
CIVI_HTTP_AUTH_PASS,
} = process.env;
const headers = {
"Content-Type": "application/x-www-form-urlencoded",
};
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
}
const url = `${CIVI_BASE_URL}/civicrm/ajax/rest`;
const body = new URLSearchParams({
entity: "Attachment",
action: "getfields",
api_key: CIVI_API_KEY,
key: CIVI_SITE_KEY,
json: "1",
});
const res = await fetch(url, { method: "POST", headers, body });
const text = await res.text();
let j;
try { j = JSON.parse(text); } catch { j = null; }
if (j && !j.is_error) {
const fields = j.values ? Object.keys(j.values) : [];
console.log(` APIv3 Attachment.getfields OK. Fields: ${fields.join(", ")}`);
} else {
console.log(` APIv3 Attachment.getfields response:`, text.slice(0, 400));
}
} catch (err) {
console.log(` APIv3 probe failed: ${err.message}`);
}