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:
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import type { FieldConfig, SelectOption } from "@/types/form";
|
||||
import type {
|
||||
UseFormRegister,
|
||||
UseFormSetValue,
|
||||
FieldValues,
|
||||
FieldErrors,
|
||||
Control,
|
||||
@@ -12,6 +14,9 @@ import type {
|
||||
interface FieldRendererProps {
|
||||
field: FieldConfig;
|
||||
register: UseFormRegister<FieldValues>;
|
||||
/** RHF setValue — file fields use it to write the uploaded {id, file_name}
|
||||
* back into form state after /api/upload returns. */
|
||||
setValue: UseFormSetValue<FieldValues>;
|
||||
errors: FieldErrors;
|
||||
/** Form control — required for currency live-preview formatting. */
|
||||
control: Control<FieldValues>;
|
||||
@@ -23,6 +28,12 @@ interface FieldRendererProps {
|
||||
* `options` array if absent.
|
||||
*/
|
||||
resolvedOptions?: SelectOption[];
|
||||
/** Form auth pair, threaded through to file fields for /api/upload. */
|
||||
cid: string;
|
||||
cs: string;
|
||||
/** Called by file fields when an upload starts (+1) / finishes (-1). The
|
||||
* form uses the running count to block submit while uploads are in flight. */
|
||||
onUploadStateChange?: (delta: 1 | -1) => void;
|
||||
}
|
||||
|
||||
const DATE_MIN_DEFAULT = "1900-01-01";
|
||||
@@ -44,10 +55,14 @@ const DATE_MAX_DEFAULT = "2100-12-31";
|
||||
export function FieldRenderer({
|
||||
field,
|
||||
register,
|
||||
setValue,
|
||||
errors,
|
||||
control,
|
||||
readonlyValue,
|
||||
resolvedOptions,
|
||||
cid,
|
||||
cs,
|
||||
onUploadStateChange,
|
||||
}: FieldRendererProps) {
|
||||
const id = `field-${field.name}`;
|
||||
const helpId = field.help ? `${id}-help` : undefined;
|
||||
@@ -218,21 +233,21 @@ export function FieldRenderer({
|
||||
// ── File ────────────────────────────────────────────────────────────────
|
||||
if (field.type === "file") {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label id={id} field={field} />
|
||||
<FilePriorIndicator control={control} name={field.name} />
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
{...register(field.name, { required: requiredOpt })}
|
||||
className="block w-full text-sm text-ink-soft file:mr-3 file:rounded-md file:border-0 file:bg-leaf-100 file:px-3 file:py-2 file:text-leaf-800 file:text-sm file:font-medium hover:file:bg-leaf-200 cursor-pointer transition"
|
||||
/>
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
<FileField
|
||||
field={field}
|
||||
id={id}
|
||||
helpId={helpId}
|
||||
errorId={errorId}
|
||||
errorMsg={errorMsg}
|
||||
describedBy={describedBy}
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
cid={cid}
|
||||
cs={cs}
|
||||
onUploadStateChange={onUploadStateChange}
|
||||
register={register}
|
||||
requiredOpt={requiredOpt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -433,6 +448,118 @@ function FilePriorIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload-on-pick file field. The moment the user selects a file the
|
||||
* browser sends it to /api/upload; on success we replace RHF state with
|
||||
* the returned {id, file_name}. That same shape is what submit serializes
|
||||
* and what the prefill path produces for prior attachments — so the rest
|
||||
* of the pipeline doesn't care whether the value originated as prefill
|
||||
* or as a fresh upload.
|
||||
*
|
||||
* The native <input type="file"> is intentionally NOT register()'d here:
|
||||
* its `value` is a FileList that doesn't survive JSON.stringify, which is
|
||||
* the whole bug we're closing. We manage state manually via setValue.
|
||||
*/
|
||||
function FileField({
|
||||
field,
|
||||
id,
|
||||
helpId,
|
||||
errorId,
|
||||
errorMsg,
|
||||
describedBy,
|
||||
control,
|
||||
setValue,
|
||||
cid,
|
||||
cs,
|
||||
onUploadStateChange,
|
||||
}: {
|
||||
field: FieldConfig;
|
||||
id: string;
|
||||
helpId?: string;
|
||||
errorId?: string;
|
||||
errorMsg?: string;
|
||||
describedBy?: string;
|
||||
control: Control<FieldValues>;
|
||||
setValue: UseFormSetValue<FieldValues>;
|
||||
cid: string;
|
||||
cs: string;
|
||||
onUploadStateChange?: (delta: 1 | -1) => void;
|
||||
register: UseFormRegister<FieldValues>;
|
||||
requiredOpt: string | false;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
// Target Civi field reference — whichever side this field maps to.
|
||||
const fieldRef = field.civiField ?? field.civiContactField ?? "";
|
||||
|
||||
async function handlePick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
onUploadStateChange?.(1);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set("file", file);
|
||||
fd.set("cid", cid);
|
||||
fd.set("cs", cs);
|
||||
fd.set("fieldRef", fieldRef);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
id?: number;
|
||||
file_name?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok) {
|
||||
setUploadError(json.error ?? `Upload failed (HTTP ${res.status}).`);
|
||||
// Reset the file input so the user can retry the same file.
|
||||
e.target.value = "";
|
||||
} else if (json.id != null && json.file_name) {
|
||||
setValue(field.name, { id: json.id, file_name: json.file_name }, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
} else {
|
||||
setUploadError("Upload succeeded but no file id was returned.");
|
||||
}
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : "Upload network error.");
|
||||
e.target.value = "";
|
||||
} finally {
|
||||
setUploading(false);
|
||||
onUploadStateChange?.(-1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label id={id} field={field} />
|
||||
<FilePriorIndicator control={control} name={field.name} />
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
disabled={uploading}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={errorMsg || uploadError ? true : undefined}
|
||||
aria-required={field.required || undefined}
|
||||
onChange={handlePick}
|
||||
className="block w-full text-sm text-ink-soft file:mr-3 file:rounded-md file:border-0 file:bg-leaf-100 file:px-3 file:py-2 file:text-leaf-800 file:text-sm file:font-medium hover:file:bg-leaf-200 cursor-pointer transition disabled:cursor-wait disabled:opacity-60"
|
||||
/>
|
||||
{uploading && (
|
||||
<p role="status" aria-live="polite" className="text-xs text-ink-soft">
|
||||
Uploading…
|
||||
</p>
|
||||
)}
|
||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
||||
{uploadError && <ErrorText id={errorId ?? `${id}-error`}>{uploadError}</ErrorText>}
|
||||
{!uploadError && errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ id, field }: { id: string; field: FieldConfig }) {
|
||||
return (
|
||||
<label htmlFor={id} className="block text-sm font-medium text-ink">
|
||||
|
||||
Reference in New Issue
Block a user