#!/usr/bin/env node // scripts/spike-file-upload.mjs // // Supersedes spike-attachment-upload.mjs. The earlier spike showed APIv4 // Attachment.create is not exposed on this Civi instance, but APIv4 // `File` and `EntityFile` ARE present, and APIv3 Attachment is reachable // as a fallback. // // CiviCRM custom file fields store the file id directly in the custom // column on the entity's custom-value table -- the EntityFile linkage // table is only needed for general attachments (e.g. on an Activity's // "Attachments" tab). So for our form's custom-field-bound files we // only need: // // File row in civicrm_file <-- File.create // │ // │ (file id stored directly as the custom field value) // ▼ // Custom field on the entity <-- Contact.update / Activity.create // // This spike confirms that pipeline end-to-end on a real org contact. // // USAGE // node --env-file=.env.local scripts/spike-file-upload.mjs --org-id= [--keep] import { Buffer } from "node:buffer"; const args = process.argv.slice(2); const orgIdArg = args.find((a) => a.startsWith("--org-id=")); const KEEP = args.includes("--keep"); const ORG_ID = orgIdArg ? Number(orgIdArg.slice("--org-id=".length)) : null; if (!ORG_ID) { console.error( "Usage: node --env-file=.env.local scripts/spike-file-upload.mjs --org-id= [--keep]", ); process.exit(1); } const CUSTOM_FIELD = "Food_Co_op_Organizing.Certificate_of_Incorporation"; const TEST_FILENAME = `spike-${Date.now()}.txt`; const TEST_MIME = "text/plain"; const TEST_BODY = "civi-webform file spike — safe to delete"; 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; } function divider(label) { console.log(`\n── ${label} ${"─".repeat(Math.max(0, 60 - label.length))}`); } // ── Q1: what fields does APIv4 File.create accept on this Civi? divider("Q1: File.getFields"); const fields = await civi("File", "getFields", { action: "create" }); const fieldNames = (fields.values ?? []).map((f) => f.name); console.log("File create-action fields:", fieldNames.join(", ")); const acceptsContent = fieldNames.includes("content"); console.log(`Accepts 'content' param: ${acceptsContent ? "yes ✓" : "NO -- must POST file differently"}`); if (!acceptsContent) { console.log( "\nFile.create on this Civi doesn't accept inline content. The upload path", "needs to use a different mechanism (likely the legacy APIv3 Attachment.create", "or the civicrm/upload endpoint). Stopping spike to avoid guessing.", ); process.exit(2); } // ── Q2: create a File row. divider("Q2: File.create with base64 content"); const created = await civi("File", "create", { values: { file_name: TEST_FILENAME, mime_type: TEST_MIME, content: Buffer.from(TEST_BODY, "utf8").toString("base64"), }, }); const fileId = created.values?.[0]?.id; console.log("File.create result:", JSON.stringify(created, null, 2)); if (!fileId) { console.log("RESULT: failed — no id returned"); process.exit(2); } console.log(`RESULT: file id = ${fileId} ✓`); // ── Q3: read current value, write file id to custom field, read back. divider(`Q3: Contact.update ${CUSTOM_FIELD} = ${fileId}`); const before = await civi("Contact", "get", { select: ["id", CUSTOM_FIELD, `${CUSTOM_FIELD}.file_name`], where: [["id", "=", ORG_ID]], }); const priorRow = before.values?.[0] ?? {}; const priorValue = priorRow[CUSTOM_FIELD] ?? null; console.log("Prior value on contact:", JSON.stringify(priorRow, null, 2)); let writeOk = false; try { const upd = await civi("Contact", "update", { where: [["id", "=", ORG_ID]], values: { [CUSTOM_FIELD]: fileId }, }); console.log("Update result:", JSON.stringify(upd, null, 2)); writeOk = true; console.log("RESULT: write OK ✓"); } catch (err) { console.log(`Update failed: ${err.message}`); } // ── Q4: read back through the join /api/data uses. if (writeOk) { divider("Q4: read-back via Contact.get + .file_name join"); const after = await civi("Contact", "get", { select: ["id", "display_name", CUSTOM_FIELD, `${CUSTOM_FIELD}.file_name`], where: [["id", "=", ORG_ID]], }); console.log(JSON.stringify(after, null, 2)); const row = after.values?.[0]; if (row && Number(row[CUSTOM_FIELD]) === Number(fileId) && row[`${CUSTOM_FIELD}.file_name`]) { console.log("RESULT: round-trip OK ✓ — pipeline is viable"); } else { console.log("RESULT: read-back incomplete or id mismatch — see payload"); } } // ── Cleanup: restore prior value, delete file row. if (KEEP) { console.log(`\n--keep set; leaving file id=${fileId} and contact pointing at it.`); } else { divider("Cleanup: restore prior contact value + File.delete"); try { await civi("Contact", "update", { where: [["id", "=", ORG_ID]], values: { [CUSTOM_FIELD]: priorValue }, }); console.log(`Restored ${CUSTOM_FIELD} = ${priorValue}`); } catch (err) { console.log("Restore failed:", err.message); } try { const del = await civi("File", "delete", { where: [["id", "=", fileId]] }); console.log(`File.delete id=${fileId}:`, JSON.stringify(del)); console.log("Cleanup OK ✓"); } catch (err) { console.log("File.delete failed:", err.message); console.log(`*** MANUAL CLEANUP NEEDED: File id=${fileId} is orphaned in CiviCRM ***`); } } console.log("\nSpike complete.");