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:
@@ -4,6 +4,7 @@ import { useEffect, useId, useMemo, useState } from "react";
|
||||
import type { FieldConfig, StageSectionConfig, SelectOption } from "@/types/form";
|
||||
import type {
|
||||
UseFormRegister,
|
||||
UseFormSetValue,
|
||||
FieldValues,
|
||||
FieldErrors,
|
||||
Control,
|
||||
@@ -16,6 +17,7 @@ import { evaluate } from "@/lib/conditional";
|
||||
interface StageSectionProps {
|
||||
section: StageSectionConfig;
|
||||
register: UseFormRegister<FieldValues>;
|
||||
setValue: UseFormSetValue<FieldValues>;
|
||||
control: Control<FieldValues>;
|
||||
errors: FieldErrors;
|
||||
/** Live form values, used to evaluate per-field visibility rules. */
|
||||
@@ -32,6 +34,12 @@ interface StageSectionProps {
|
||||
defaultOpen: boolean;
|
||||
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
|
||||
options: Record<number, SelectOption[]>;
|
||||
/** Form auth pair, passed through to file fields for /api/upload. */
|
||||
cid: string;
|
||||
cs: string;
|
||||
/** Called by file fields when an upload starts/finishes; lets the form
|
||||
* track in-flight uploads and block submit until they settle. */
|
||||
onUploadStateChange?: (delta: 1 | -1) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,6 +56,7 @@ interface StageSectionProps {
|
||||
export function StageSection({
|
||||
section,
|
||||
register,
|
||||
setValue,
|
||||
control,
|
||||
errors,
|
||||
formValues,
|
||||
@@ -55,6 +64,9 @@ export function StageSection({
|
||||
locked,
|
||||
defaultOpen,
|
||||
options,
|
||||
cid,
|
||||
cs,
|
||||
onUploadStateChange,
|
||||
}: StageSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const headingId = useId();
|
||||
@@ -228,8 +240,12 @@ export function StageSection({
|
||||
formValues={formValues}
|
||||
options={options}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
control={control}
|
||||
errors={errors}
|
||||
cid={cid}
|
||||
cs={cs}
|
||||
onUploadStateChange={onUploadStateChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -251,10 +267,14 @@ export function StageSection({
|
||||
<FieldRenderer
|
||||
field={f}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
control={control}
|
||||
errors={errors}
|
||||
readonlyValue={formValues[f.name]}
|
||||
resolvedOptions={resolvedOptions}
|
||||
cid={cid}
|
||||
cs={cs}
|
||||
onUploadStateChange={onUploadStateChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -360,8 +380,12 @@ function FieldGroupCard({
|
||||
formValues,
|
||||
options,
|
||||
register,
|
||||
setValue,
|
||||
control,
|
||||
errors,
|
||||
cid,
|
||||
cs,
|
||||
onUploadStateChange,
|
||||
}: {
|
||||
label?: string;
|
||||
intro?: string;
|
||||
@@ -369,8 +393,12 @@ function FieldGroupCard({
|
||||
formValues: Record<string, unknown>;
|
||||
options: Record<number, SelectOption[]>;
|
||||
register: UseFormRegister<FieldValues>;
|
||||
setValue: UseFormSetValue<FieldValues>;
|
||||
control: Control<FieldValues>;
|
||||
errors: FieldErrors;
|
||||
cid: string;
|
||||
cs: string;
|
||||
onUploadStateChange?: (delta: 1 | -1) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-l-2 border-leaf-300/60 pl-3 sm:pl-4">
|
||||
@@ -404,10 +432,14 @@ function FieldGroupCard({
|
||||
<FieldRenderer
|
||||
field={f}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
control={control}
|
||||
errors={errors}
|
||||
readonlyValue={formValues[f.name]}
|
||||
resolvedOptions={resolvedOptions}
|
||||
cid={cid}
|
||||
cs={cs}
|
||||
onUploadStateChange={onUploadStateChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user