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:
+43
-23
@@ -25,7 +25,7 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { verifyChecksum } from "@/lib/civicrm";
|
||||
import { civi, verifyChecksum } from "@/lib/civicrm";
|
||||
import { allFields } from "@/config/form";
|
||||
import { rateLimit, clientIp } from "@/lib/rate-limit";
|
||||
|
||||
@@ -205,29 +205,49 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(file-pipeline-phase-1): wire CiviCRM Attachment.create.
|
||||
// APIv4 File.create with inline base64 content. Spike (June 2026) on
|
||||
// this Civi instance confirmed:
|
||||
// - APIv4 Attachment is NOT exposed
|
||||
// - APIv4 File + EntityFile ARE exposed
|
||||
// - File.create with file_name + mime_type + content (base64) returns
|
||||
// a usable file id
|
||||
// - Custom file fields store the file id directly in the custom
|
||||
// column, so EntityFile linkage is not needed for our use case
|
||||
// - Round-trip via Contact.update + Contact.get .file_name join works
|
||||
//
|
||||
// Awaiting spike output from scripts/spike-attachment-upload.mjs to
|
||||
// determine which of two patterns to use:
|
||||
// We do not create EntityFile rows here. Civi's custom-field renderer
|
||||
// joins through the custom column to civicrm_file directly, and the
|
||||
// form-side prefill/read code in /api/data uses the same join.
|
||||
//
|
||||
// A. Unbound: Attachment.create with no entity_table/entity_id, then
|
||||
// use the returned id as the field value at submit time. Preferred.
|
||||
// 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 (for stage-N file fields)
|
||||
// or to the org contact custom field (for Food_Co_op_Organizing.*).
|
||||
//
|
||||
// B. Bound-at-upload: Attachment.create requires entity_table +
|
||||
// entity_id. For contact-bound fields (Food_Co_op_Organizing.*)
|
||||
// we can bind to the org contact. For activity-bound fields the
|
||||
// activity doesn't exist yet — we'd need to attach to the org
|
||||
// contact temporarily, then re-link to the activity post-create
|
||||
// (or restructure submit to two-phase: create activity, then attach).
|
||||
//
|
||||
// Until the spike resolves, this endpoint returns 501 so it can't
|
||||
// silently confuse the frontend.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Upload pipeline pending: CiviCRM Attachment.create wiring blocked on spike. " +
|
||||
"See scripts/spike-attachment-upload.mjs.",
|
||||
},
|
||||
{ status: 501 },
|
||||
);
|
||||
// Orphan files: if the user uploads and then abandons the form, the
|
||||
// File row persists with no entity referencing it. Cleanup is handled
|
||||
// by a CiviCRM scheduled job (configured separately by the Civi admin)
|
||||
// that deletes File rows with no inbound references older than ~24h.
|
||||
let fileId: number;
|
||||
try {
|
||||
const res = await civi<{ id: number }>("File", "create", {
|
||||
values: {
|
||||
file_name: safeName,
|
||||
mime_type: clientMime,
|
||||
content: Buffer.from(bytes).toString("base64"),
|
||||
},
|
||||
});
|
||||
const id = res.values?.[0]?.id;
|
||||
if (!id) throw new Error("File.create returned no id");
|
||||
fileId = Number(id);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error("[upload] File.create failed:", msg);
|
||||
return NextResponse.json(
|
||||
{ error: "Could not save the upload. Please try again." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ id: fileId, file_name: safeName });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user