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:
Joel Brock
2026-06-05 07:48:57 -07:00
parent 8159b87074
commit 2400931a04
9 changed files with 563 additions and 49 deletions
+4 -4
View File
@@ -170,11 +170,11 @@ export async function GET(req: NextRequest) {
.map((f) => f.civiContactField)
.filter((s): s is string => Boolean(s));
// For file-typed org-contact fields, also pull the joined .file_name so
// we can surface a human-readable filename in the readonly indicator.
// the prior-attachment indicator shows the filename, not just the file id.
const orgContactFileRefs = allFields
.filter((f) => f.type === "file" || (f.type === "readonly" && f.civiContactField?.toLowerCase().includes("certificate")))
.map((f) => f.civiContactField)
.filter((s): s is string => Boolean(s));
.filter((f) => f.type === "file" && f.civiContactField)
.map((f) => f.civiContactField!)
.filter(Boolean);
const orgContactSelect = [
"id",
"display_name",
+14 -2
View File
@@ -120,10 +120,22 @@ async function runSubmit(cid: string, cs: string, values: Record<string, unknown
const field = FIELD_BY_NAME.get(name);
if (!field) continue;
if (field.type === "readonly") continue; // never write read-only fields
// File fields: the renderer uploads to /api/upload on file-pick and
// stores {id, file_name} in form state. Submit only needs the id —
// that's what Civi stores in the custom column. If the user left a
// prior attachment alone, we receive the same prefill shape and
// still write the same id (no-op effectively).
let civiValue: unknown = value;
if (field.type === "file" && value && typeof value === "object" && !Array.isArray(value)) {
const v = value as { id?: unknown };
civiValue = typeof v.id === "number" || typeof v.id === "string" ? v.id : null;
}
if (field.civiContactField) {
orgContactValues[field.civiContactField] = value;
orgContactValues[field.civiContactField] = civiValue;
} else if (field.civiField) {
activityRecord[field.civiField] = value;
activityRecord[field.civiField] = civiValue;
}
}
+43 -23
View File
@@ -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 });
}