From e42e3b70efa8cb59406bcaa9201e113e8a505d1c Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Wed, 10 Jun 2026 12:16:32 -0700 Subject: [PATCH] Upload: APIv3 Attachment.create with base64 content (not multipart) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous multipart attempt (f74fd07) was rejected by Civi with "Mandatory key(s) missing: id or content or options.move-file" — the /civicrm/ajax/rest endpoint on this install doesn't expose $_FILES to v3 actions; only params['content'] is consulted. Our multipart `file` part was ignored. APIv3 Attachment.create has historically been the file-upload entry point used by Civi's own form widgets and auto-decodes the `content` field from base64, unlike v4 File.create which stores it verbatim. Send the same shape we tried first (entity_table, entity_id, name, mime_type, content=base64) but to v3 Attachment instead of v4 File. The civi3Upload helper in lib/civicrm.ts is kept in place — it's not useful for this endpoint but the multipart-POST shape may be needed later for other Civi entities that do read $_FILES. --- app/api/upload/route.ts | 62 +++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 8ea5254..319b3d6 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -25,7 +25,7 @@ */ import { NextResponse } from "next/server"; -import { civi3Upload, verifyChecksum } from "@/lib/civicrm"; +import { civi3, verifyChecksum } from "@/lib/civicrm"; import { allFields } from "@/config/form"; import { rateLimit, clientIp } from "@/lib/rate-limit"; @@ -205,43 +205,39 @@ export async function POST(req: Request) { ); } - // APIv3 Attachment.create with a multipart `file` part. The earlier - // approach used APIv4 File.create with `content: base64String` and that - // Civi on this install stores the literal base64 *text* on disk — every - // file came back corrupted (hex 6956... = "iVBO..." = base64 PNG header). - // APIv3 Attachment.create reads $_FILES['file'] from the multipart body - // and writes the bytes through unchanged. + // APIv3 Attachment.create with `content` as base64. Path history: // - // Attachment.create requires (entity_table, entity_id). Our custom-field - // flow stores the file id directly in the custom column (no - // civicrm_entity_file linkage needed for prefill/download), but the API - // still mandates an entity context for the upload call itself, so we - // anchor to the form-filler's contact id. The resulting civicrm_entity_file - // row is metadata-only — Civi's custom-field joins ignore it. + // 1. APIv4 File.create + content:base64 → stored base64 *text* on disk + // (Civi v4 doesn't decode). Every file came back corrupt. + // 2. APIv3 Attachment.create + multipart file part → Civi rejected with + // "Mandatory key(s) missing: id or content or options.move-file". + // The /civicrm/ajax/rest endpoint on this install doesn't expose + // $_FILES to the v3 action; only params['content'] is consulted. + // 3. APIv3 Attachment.create + content:base64 (this code) → Civi's v3 + // Attachment.create has historically been the file-upload entry + // point used by Civi's own form widgets, and it auto-decodes the + // content field. // - // The returned id is what the frontend stores in RHF state and ultimately - // sends as the field value on /api/submit. /api/submit then writes that - // id to the activity custom field (stage-N) or to the org contact custom - // field (Food_Co_op_Organizing.*). + // Attachment.create requires (entity_table, entity_id); our custom-field + // flow stores the file id directly in the custom column (no entity_file + // linkage needed for prefill/download), but the API still mandates an + // entity context, so we anchor to the form-filler's contact id. The + // resulting civicrm_entity_file row is metadata-only — Civi's custom- + // field joins ignore it. // - // Orphan files: if the user uploads and then abandons the form, the - // civicrm_file + civicrm_entity_file rows persist with no business - // reference. Cleanup is handled by a CiviCRM scheduled job (configured - // separately by the Civi admin) that prunes orphans older than ~24h. + // Orphan files: civicrm_file + civicrm_entity_file rows persist if the + // user uploads and then abandons the form. Cleanup is handled by a + // CiviCRM scheduled job configured separately by the Civi admin. let fileId: number; try { - const res = await civi3Upload<{ id: string | number }>( - "Attachment", - "create", - { - entity_table: "civicrm_contact", - entity_id: Number(cid), - name: safeName, - mime_type: clientMime, - sequential: 1, - }, - { bytes, filename: safeName, mime: clientMime }, - ); + const res = await civi3<{ id: string | number }>("Attachment", "create", { + entity_table: "civicrm_contact", + entity_id: Number(cid), + name: safeName, + mime_type: clientMime, + content: Buffer.from(bytes).toString("base64"), + sequential: 1, + }); const idRaw = res.values?.[0]?.id; const id = typeof idRaw === "string" ? Number(idRaw) : idRaw; if (!id || !Number.isFinite(id)) {