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)
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
#!/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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user