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:
@@ -170,11 +170,11 @@ export async function GET(req: NextRequest) {
|
|||||||
.map((f) => f.civiContactField)
|
.map((f) => f.civiContactField)
|
||||||
.filter((s): s is string => Boolean(s));
|
.filter((s): s is string => Boolean(s));
|
||||||
// For file-typed org-contact fields, also pull the joined .file_name so
|
// 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
|
const orgContactFileRefs = allFields
|
||||||
.filter((f) => f.type === "file" || (f.type === "readonly" && f.civiContactField?.toLowerCase().includes("certificate")))
|
.filter((f) => f.type === "file" && f.civiContactField)
|
||||||
.map((f) => f.civiContactField)
|
.map((f) => f.civiContactField!)
|
||||||
.filter((s): s is string => Boolean(s));
|
.filter(Boolean);
|
||||||
const orgContactSelect = [
|
const orgContactSelect = [
|
||||||
"id",
|
"id",
|
||||||
"display_name",
|
"display_name",
|
||||||
|
|||||||
+14
-2
@@ -120,10 +120,22 @@ async function runSubmit(cid: string, cs: string, values: Record<string, unknown
|
|||||||
const field = FIELD_BY_NAME.get(name);
|
const field = FIELD_BY_NAME.get(name);
|
||||||
if (!field) continue;
|
if (!field) continue;
|
||||||
if (field.type === "readonly") continue; // never write read-only fields
|
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) {
|
if (field.civiContactField) {
|
||||||
orgContactValues[field.civiContactField] = value;
|
orgContactValues[field.civiContactField] = civiValue;
|
||||||
} else if (field.civiField) {
|
} else if (field.civiField) {
|
||||||
activityRecord[field.civiField] = value;
|
activityRecord[field.civiField] = civiValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-21
@@ -25,7 +25,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { verifyChecksum } from "@/lib/civicrm";
|
import { civi, verifyChecksum } from "@/lib/civicrm";
|
||||||
import { allFields } from "@/config/form";
|
import { allFields } from "@/config/form";
|
||||||
import { rateLimit, clientIp } from "@/lib/rate-limit";
|
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
|
// We do not create EntityFile rows here. Civi's custom-field renderer
|
||||||
// determine which of two patterns to use:
|
// 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
|
// The returned id is what the frontend stores in RHF state and
|
||||||
// use the returned id as the field value at submit time. Preferred.
|
// 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 +
|
// Orphan files: if the user uploads and then abandons the form, the
|
||||||
// entity_id. For contact-bound fields (Food_Co_op_Organizing.*)
|
// File row persists with no entity referencing it. Cleanup is handled
|
||||||
// we can bind to the org contact. For activity-bound fields the
|
// by a CiviCRM scheduled job (configured separately by the Civi admin)
|
||||||
// activity doesn't exist yet — we'd need to attach to the org
|
// that deletes File rows with no inbound references older than ~24h.
|
||||||
// contact temporarily, then re-link to the activity post-create
|
let fileId: number;
|
||||||
// (or restructure submit to two-phase: create activity, then attach).
|
try {
|
||||||
//
|
const res = await civi<{ id: number }>("File", "create", {
|
||||||
// Until the spike resolves, this endpoint returns 501 so it can't
|
values: {
|
||||||
// silently confuse the frontend.
|
file_name: safeName,
|
||||||
return NextResponse.json(
|
mime_type: clientMime,
|
||||||
{
|
content: Buffer.from(bytes).toString("base64"),
|
||||||
error:
|
|
||||||
"Upload pipeline pending: CiviCRM Attachment.create wiring blocked on spike. " +
|
|
||||||
"See scripts/spike-attachment-upload.mjs.",
|
|
||||||
},
|
},
|
||||||
{ status: 501 },
|
});
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,9 +66,17 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
control,
|
control,
|
||||||
watch,
|
watch,
|
||||||
setFocus,
|
setFocus,
|
||||||
|
setValue,
|
||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
} = useForm({ mode: "onBlur" });
|
} = useForm({ mode: "onBlur" });
|
||||||
|
|
||||||
|
// Count of file uploads currently in flight. Each <FileField> calls the
|
||||||
|
// handler with +1 when it starts and -1 when it finishes; submit is
|
||||||
|
// blocked while the count is > 0 so users can't ship a half-uploaded form.
|
||||||
|
const [uploadsInFlight, setUploadsInFlight] = useState(0);
|
||||||
|
const handleUploadStateChange = (delta: 1 | -1) =>
|
||||||
|
setUploadsInFlight((n) => Math.max(0, n + delta));
|
||||||
|
|
||||||
// Subscribe ONLY to current_stage. That's the single field that affects
|
// Subscribe ONLY to current_stage. That's the single field that affects
|
||||||
// section visibility, so re-rendering the whole form on every keystroke
|
// section visibility, so re-rendering the whole form on every keystroke
|
||||||
// (which `watch()` with no args would do) is wasteful — particularly with
|
// (which `watch()` with no args would do) is wasteful — particularly with
|
||||||
@@ -279,6 +287,19 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = async (values: Record<string, unknown>) => {
|
const onSubmit = async (values: Record<string, unknown>) => {
|
||||||
|
// Block while any file upload is in flight — submitting now would
|
||||||
|
// ship the form without the pending {id, file_name} value for that
|
||||||
|
// field, which would silently clear the prior attachment.
|
||||||
|
if (uploadsInFlight > 0) {
|
||||||
|
setSubmitState({
|
||||||
|
kind: "error",
|
||||||
|
message:
|
||||||
|
uploadsInFlight === 1
|
||||||
|
? "A file is still uploading. Please wait a moment and try again."
|
||||||
|
: `${uploadsInFlight} files are still uploading. Please wait a moment and try again.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitState({ kind: "submitting" });
|
setSubmitState({ kind: "submitting" });
|
||||||
try {
|
try {
|
||||||
// Strip values for hidden fields — never write data the user couldn't see.
|
// Strip values for hidden fields — never write data the user couldn't see.
|
||||||
@@ -393,6 +414,7 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
<StageSection
|
<StageSection
|
||||||
section={section}
|
section={section}
|
||||||
register={register}
|
register={register}
|
||||||
|
setValue={setValue}
|
||||||
control={control}
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
formValues={evalState}
|
formValues={evalState}
|
||||||
@@ -400,6 +422,9 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
|
|||||||
locked={locked}
|
locked={locked}
|
||||||
defaultOpen={pathwayState === "current" || section.rank === 0}
|
defaultOpen={pathwayState === "current" || section.rank === 0}
|
||||||
options={load.data.options ?? {}}
|
options={load.data.options ?? {}}
|
||||||
|
cid={cid}
|
||||||
|
cs={cs}
|
||||||
|
onUploadStateChange={handleUploadStateChange}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useId, useMemo, useState } from "react";
|
|||||||
import type { FieldConfig, StageSectionConfig, SelectOption } from "@/types/form";
|
import type { FieldConfig, StageSectionConfig, SelectOption } from "@/types/form";
|
||||||
import type {
|
import type {
|
||||||
UseFormRegister,
|
UseFormRegister,
|
||||||
|
UseFormSetValue,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
FieldErrors,
|
FieldErrors,
|
||||||
Control,
|
Control,
|
||||||
@@ -16,6 +17,7 @@ import { evaluate } from "@/lib/conditional";
|
|||||||
interface StageSectionProps {
|
interface StageSectionProps {
|
||||||
section: StageSectionConfig;
|
section: StageSectionConfig;
|
||||||
register: UseFormRegister<FieldValues>;
|
register: UseFormRegister<FieldValues>;
|
||||||
|
setValue: UseFormSetValue<FieldValues>;
|
||||||
control: Control<FieldValues>;
|
control: Control<FieldValues>;
|
||||||
errors: FieldErrors;
|
errors: FieldErrors;
|
||||||
/** Live form values, used to evaluate per-field visibility rules. */
|
/** Live form values, used to evaluate per-field visibility rules. */
|
||||||
@@ -32,6 +34,12 @@ interface StageSectionProps {
|
|||||||
defaultOpen: boolean;
|
defaultOpen: boolean;
|
||||||
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
|
/** Option groups fetched from CiviCRM, keyed by option_group_id. */
|
||||||
options: Record<number, SelectOption[]>;
|
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({
|
export function StageSection({
|
||||||
section,
|
section,
|
||||||
register,
|
register,
|
||||||
|
setValue,
|
||||||
control,
|
control,
|
||||||
errors,
|
errors,
|
||||||
formValues,
|
formValues,
|
||||||
@@ -55,6 +64,9 @@ export function StageSection({
|
|||||||
locked,
|
locked,
|
||||||
defaultOpen,
|
defaultOpen,
|
||||||
options,
|
options,
|
||||||
|
cid,
|
||||||
|
cs,
|
||||||
|
onUploadStateChange,
|
||||||
}: StageSectionProps) {
|
}: StageSectionProps) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
const headingId = useId();
|
const headingId = useId();
|
||||||
@@ -228,8 +240,12 @@ export function StageSection({
|
|||||||
formValues={formValues}
|
formValues={formValues}
|
||||||
options={options}
|
options={options}
|
||||||
register={register}
|
register={register}
|
||||||
|
setValue={setValue}
|
||||||
control={control}
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
|
cid={cid}
|
||||||
|
cs={cs}
|
||||||
|
onUploadStateChange={onUploadStateChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -251,10 +267,14 @@ export function StageSection({
|
|||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
field={f}
|
field={f}
|
||||||
register={register}
|
register={register}
|
||||||
|
setValue={setValue}
|
||||||
control={control}
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
readonlyValue={formValues[f.name]}
|
readonlyValue={formValues[f.name]}
|
||||||
resolvedOptions={resolvedOptions}
|
resolvedOptions={resolvedOptions}
|
||||||
|
cid={cid}
|
||||||
|
cs={cs}
|
||||||
|
onUploadStateChange={onUploadStateChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -360,8 +380,12 @@ function FieldGroupCard({
|
|||||||
formValues,
|
formValues,
|
||||||
options,
|
options,
|
||||||
register,
|
register,
|
||||||
|
setValue,
|
||||||
control,
|
control,
|
||||||
errors,
|
errors,
|
||||||
|
cid,
|
||||||
|
cs,
|
||||||
|
onUploadStateChange,
|
||||||
}: {
|
}: {
|
||||||
label?: string;
|
label?: string;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
@@ -369,8 +393,12 @@ function FieldGroupCard({
|
|||||||
formValues: Record<string, unknown>;
|
formValues: Record<string, unknown>;
|
||||||
options: Record<number, SelectOption[]>;
|
options: Record<number, SelectOption[]>;
|
||||||
register: UseFormRegister<FieldValues>;
|
register: UseFormRegister<FieldValues>;
|
||||||
|
setValue: UseFormSetValue<FieldValues>;
|
||||||
control: Control<FieldValues>;
|
control: Control<FieldValues>;
|
||||||
errors: FieldErrors;
|
errors: FieldErrors;
|
||||||
|
cid: string;
|
||||||
|
cs: string;
|
||||||
|
onUploadStateChange?: (delta: 1 | -1) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="border-l-2 border-leaf-300/60 pl-3 sm:pl-4">
|
<div className="border-l-2 border-leaf-300/60 pl-3 sm:pl-4">
|
||||||
@@ -404,10 +432,14 @@ function FieldGroupCard({
|
|||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
field={f}
|
field={f}
|
||||||
register={register}
|
register={register}
|
||||||
|
setValue={setValue}
|
||||||
control={control}
|
control={control}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
readonlyValue={formValues[f.name]}
|
readonlyValue={formValues[f.name]}
|
||||||
resolvedOptions={resolvedOptions}
|
resolvedOptions={resolvedOptions}
|
||||||
|
cid={cid}
|
||||||
|
cs={cs}
|
||||||
|
onUploadStateChange={onUploadStateChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useWatch } from "react-hook-form";
|
import { useWatch } from "react-hook-form";
|
||||||
import type { FieldConfig, SelectOption } from "@/types/form";
|
import type { FieldConfig, SelectOption } from "@/types/form";
|
||||||
import type {
|
import type {
|
||||||
UseFormRegister,
|
UseFormRegister,
|
||||||
|
UseFormSetValue,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
FieldErrors,
|
FieldErrors,
|
||||||
Control,
|
Control,
|
||||||
@@ -12,6 +14,9 @@ import type {
|
|||||||
interface FieldRendererProps {
|
interface FieldRendererProps {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
register: UseFormRegister<FieldValues>;
|
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;
|
errors: FieldErrors;
|
||||||
/** Form control — required for currency live-preview formatting. */
|
/** Form control — required for currency live-preview formatting. */
|
||||||
control: Control<FieldValues>;
|
control: Control<FieldValues>;
|
||||||
@@ -23,6 +28,12 @@ interface FieldRendererProps {
|
|||||||
* `options` array if absent.
|
* `options` array if absent.
|
||||||
*/
|
*/
|
||||||
resolvedOptions?: SelectOption[];
|
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";
|
const DATE_MIN_DEFAULT = "1900-01-01";
|
||||||
@@ -44,10 +55,14 @@ const DATE_MAX_DEFAULT = "2100-12-31";
|
|||||||
export function FieldRenderer({
|
export function FieldRenderer({
|
||||||
field,
|
field,
|
||||||
register,
|
register,
|
||||||
|
setValue,
|
||||||
errors,
|
errors,
|
||||||
control,
|
control,
|
||||||
readonlyValue,
|
readonlyValue,
|
||||||
resolvedOptions,
|
resolvedOptions,
|
||||||
|
cid,
|
||||||
|
cs,
|
||||||
|
onUploadStateChange,
|
||||||
}: FieldRendererProps) {
|
}: FieldRendererProps) {
|
||||||
const id = `field-${field.name}`;
|
const id = `field-${field.name}`;
|
||||||
const helpId = field.help ? `${id}-help` : undefined;
|
const helpId = field.help ? `${id}-help` : undefined;
|
||||||
@@ -218,21 +233,21 @@ export function FieldRenderer({
|
|||||||
// ── File ────────────────────────────────────────────────────────────────
|
// ── File ────────────────────────────────────────────────────────────────
|
||||||
if (field.type === "file") {
|
if (field.type === "file") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<FileField
|
||||||
<Label id={id} field={field} />
|
field={field}
|
||||||
<FilePriorIndicator control={control} name={field.name} />
|
|
||||||
<input
|
|
||||||
id={id}
|
id={id}
|
||||||
type="file"
|
helpId={helpId}
|
||||||
aria-describedby={describedBy}
|
errorId={errorId}
|
||||||
aria-invalid={errorMsg ? true : undefined}
|
errorMsg={errorMsg}
|
||||||
aria-required={field.required || undefined}
|
describedBy={describedBy}
|
||||||
{...register(field.name, { required: requiredOpt })}
|
control={control}
|
||||||
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"
|
setValue={setValue}
|
||||||
|
cid={cid}
|
||||||
|
cs={cs}
|
||||||
|
onUploadStateChange={onUploadStateChange}
|
||||||
|
register={register}
|
||||||
|
requiredOpt={requiredOpt}
|
||||||
/>
|
/>
|
||||||
{field.help && <Help id={helpId!}>{field.help}</Help>}
|
|
||||||
{errorMsg && <ErrorText id={errorId!}>{errorMsg}</ErrorText>}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 }) {
|
function Label({ id, field }: { id: string; field: FieldConfig }) {
|
||||||
return (
|
return (
|
||||||
<label htmlFor={id} className="block text-sm font-medium text-ink">
|
<label htmlFor={id} className="block text-sm font-medium text-ink">
|
||||||
|
|||||||
+2
-5
@@ -111,14 +111,11 @@ const stage0: StageSectionConfig = {
|
|||||||
help: "The legal name as it appears on the incorporation certificate.",
|
help: "The legal name as it appears on the incorporation certificate.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Read-only until the form gains a file-upload pipeline. The field
|
|
||||||
// is shown as the prior attachment filename; fresh uploads aren't
|
|
||||||
// supported because /api/submit serializes as JSON (FileList drops).
|
|
||||||
name: "Certificate_of_Incorporation",
|
name: "Certificate_of_Incorporation",
|
||||||
label: "Certificate of Incorporation",
|
label: "Certificate of Incorporation",
|
||||||
type: "readonly",
|
type: "file",
|
||||||
civiContactField: `${G_ORG}.Certificate_of_Incorporation`,
|
civiContactField: `${G_ORG}.Certificate_of_Incorporation`,
|
||||||
help: "Contact staff to update the certificate on file.",
|
help: "Upload a PDF, Word doc, or image of the incorporation certificate (max 5 MB).",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Equity_share",
|
name: "Equity_share",
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// scripts/list-civi-entities.mjs
|
||||||
|
//
|
||||||
|
// Lists what entities APIv4 exposes on this Civi instance, with a focus
|
||||||
|
// on file/attachment-shaped ones. Run this when Attachment.create comes
|
||||||
|
// back "API does not exist", to figure out what the real upload path is.
|
||||||
|
//
|
||||||
|
// USAGE
|
||||||
|
// node --env-file=.env.local scripts/list-civi-entities.mjs
|
||||||
|
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
|
async function civi(entity, action, params) {
|
||||||
|
const {
|
||||||
|
CIVI_BASE_URL,
|
||||||
|
CIVI_API_KEY,
|
||||||
|
CIVI_SITE_KEY,
|
||||||
|
CIVI_HTTP_AUTH_USER,
|
||||||
|
CIVI_HTTP_AUTH_PASS,
|
||||||
|
} = process.env;
|
||||||
|
if (!CIVI_BASE_URL || !CIVI_API_KEY || !CIVI_SITE_KEY) {
|
||||||
|
throw new Error("Missing CIVI_BASE_URL / CIVI_API_KEY / CIVI_SITE_KEY.");
|
||||||
|
}
|
||||||
|
const url = `${CIVI_BASE_URL}/civicrm/ajax/api4/${entity}/${action}`;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"X-Civi-Auth": `Bearer ${CIVI_API_KEY}`,
|
||||||
|
"X-Civi-Key": CIVI_SITE_KEY,
|
||||||
|
};
|
||||||
|
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
|
||||||
|
headers["Authorization"] =
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: new URLSearchParams({ params: JSON.stringify(params) }),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(text); } catch {
|
||||||
|
throw new Error(`${entity}.${action} non-JSON (HTTP ${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
if (!res.ok || json.error_message) {
|
||||||
|
throw new Error(`${entity}.${action} HTTP ${res.status}: ${json.error_message ?? text}`);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Probing APIv4 entities…\n");
|
||||||
|
|
||||||
|
// All registered entities.
|
||||||
|
const all = await civi("Entity", "get", { select: ["name"], orderBy: { name: "ASC" } });
|
||||||
|
const names = (all.values ?? []).map((r) => r.name);
|
||||||
|
|
||||||
|
console.log(`Total APIv4 entities: ${names.length}\n`);
|
||||||
|
|
||||||
|
const fileShaped = names.filter((n) =>
|
||||||
|
/attach|file|document|upload/i.test(n),
|
||||||
|
);
|
||||||
|
console.log("File/attachment-shaped entities present:");
|
||||||
|
for (const n of fileShaped) console.log(` - ${n}`);
|
||||||
|
if (fileShaped.length === 0) console.log(" (none)");
|
||||||
|
|
||||||
|
console.log("\nFor each, list available actions:");
|
||||||
|
for (const ent of fileShaped) {
|
||||||
|
try {
|
||||||
|
const a = await civi(ent, "getActions", { select: ["name"] });
|
||||||
|
const actions = (a.values ?? []).map((r) => r.name).join(", ");
|
||||||
|
console.log(`\n ${ent}: ${actions}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`\n ${ent}: <getActions failed: ${err.message}>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also probe: does the legacy APIv3 Attachment.create exist? APIv4
|
||||||
|
// extension surface is different from APIv3, and the form may need to
|
||||||
|
// fall back to v3 for files. Round-trip a getfields call as a probe.
|
||||||
|
console.log("\nAPIv3 probe (extern/rest.php):");
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
CIVI_BASE_URL,
|
||||||
|
CIVI_API_KEY,
|
||||||
|
CIVI_SITE_KEY,
|
||||||
|
CIVI_HTTP_AUTH_USER,
|
||||||
|
CIVI_HTTP_AUTH_PASS,
|
||||||
|
} = process.env;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
};
|
||||||
|
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
|
||||||
|
headers["Authorization"] =
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
|
||||||
|
}
|
||||||
|
const url = `${CIVI_BASE_URL}/civicrm/ajax/rest`;
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
entity: "Attachment",
|
||||||
|
action: "getfields",
|
||||||
|
api_key: CIVI_API_KEY,
|
||||||
|
key: CIVI_SITE_KEY,
|
||||||
|
json: "1",
|
||||||
|
});
|
||||||
|
const res = await fetch(url, { method: "POST", headers, body });
|
||||||
|
const text = await res.text();
|
||||||
|
let j;
|
||||||
|
try { j = JSON.parse(text); } catch { j = null; }
|
||||||
|
if (j && !j.is_error) {
|
||||||
|
const fields = j.values ? Object.keys(j.values) : [];
|
||||||
|
console.log(` APIv3 Attachment.getfields OK. Fields: ${fields.join(", ")}`);
|
||||||
|
} else {
|
||||||
|
console.log(` APIv3 Attachment.getfields response:`, text.slice(0, 400));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` APIv3 probe failed: ${err.message}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// scripts/spike-file-upload.mjs
|
||||||
|
//
|
||||||
|
// Supersedes spike-attachment-upload.mjs. The earlier spike showed APIv4
|
||||||
|
// Attachment.create is not exposed on this Civi instance, but APIv4
|
||||||
|
// `File` and `EntityFile` ARE present, and APIv3 Attachment is reachable
|
||||||
|
// as a fallback.
|
||||||
|
//
|
||||||
|
// CiviCRM custom file fields store the file id directly in the custom
|
||||||
|
// column on the entity's custom-value table -- the EntityFile linkage
|
||||||
|
// table is only needed for general attachments (e.g. on an Activity's
|
||||||
|
// "Attachments" tab). So for our form's custom-field-bound files we
|
||||||
|
// only need:
|
||||||
|
//
|
||||||
|
// File row in civicrm_file <-- File.create
|
||||||
|
// │
|
||||||
|
// │ (file id stored directly as the custom field value)
|
||||||
|
// ▼
|
||||||
|
// Custom field on the entity <-- Contact.update / Activity.create
|
||||||
|
//
|
||||||
|
// This spike confirms that pipeline end-to-end on a real org contact.
|
||||||
|
//
|
||||||
|
// USAGE
|
||||||
|
// node --env-file=.env.local scripts/spike-file-upload.mjs --org-id=<n> [--keep]
|
||||||
|
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const orgIdArg = args.find((a) => a.startsWith("--org-id="));
|
||||||
|
const KEEP = args.includes("--keep");
|
||||||
|
const ORG_ID = orgIdArg ? Number(orgIdArg.slice("--org-id=".length)) : null;
|
||||||
|
|
||||||
|
if (!ORG_ID) {
|
||||||
|
console.error(
|
||||||
|
"Usage: node --env-file=.env.local scripts/spike-file-upload.mjs --org-id=<n> [--keep]",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUSTOM_FIELD = "Food_Co_op_Organizing.Certificate_of_Incorporation";
|
||||||
|
const TEST_FILENAME = `spike-${Date.now()}.txt`;
|
||||||
|
const TEST_MIME = "text/plain";
|
||||||
|
const TEST_BODY = "civi-webform file spike — safe to delete";
|
||||||
|
|
||||||
|
async function civi(entity, action, params) {
|
||||||
|
const {
|
||||||
|
CIVI_BASE_URL,
|
||||||
|
CIVI_API_KEY,
|
||||||
|
CIVI_SITE_KEY,
|
||||||
|
CIVI_HTTP_AUTH_USER,
|
||||||
|
CIVI_HTTP_AUTH_PASS,
|
||||||
|
} = process.env;
|
||||||
|
if (!CIVI_BASE_URL || !CIVI_API_KEY || !CIVI_SITE_KEY) {
|
||||||
|
throw new Error("Missing CIVI_BASE_URL / CIVI_API_KEY / CIVI_SITE_KEY.");
|
||||||
|
}
|
||||||
|
const url = `${CIVI_BASE_URL}/civicrm/ajax/api4/${entity}/${action}`;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"X-Civi-Auth": `Bearer ${CIVI_API_KEY}`,
|
||||||
|
"X-Civi-Key": CIVI_SITE_KEY,
|
||||||
|
};
|
||||||
|
if (CIVI_HTTP_AUTH_USER && CIVI_HTTP_AUTH_PASS) {
|
||||||
|
headers["Authorization"] =
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(`${CIVI_HTTP_AUTH_USER}:${CIVI_HTTP_AUTH_PASS}`).toString("base64");
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: new URLSearchParams({ params: JSON.stringify(params) }),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(text); } catch {
|
||||||
|
throw new Error(`${entity}.${action} non-JSON (HTTP ${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
if (!res.ok || json.error_message) {
|
||||||
|
throw new Error(`${entity}.${action} HTTP ${res.status}: ${json.error_message ?? text}`);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
function divider(label) {
|
||||||
|
console.log(`\n── ${label} ${"─".repeat(Math.max(0, 60 - label.length))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Q1: what fields does APIv4 File.create accept on this Civi?
|
||||||
|
divider("Q1: File.getFields");
|
||||||
|
const fields = await civi("File", "getFields", { action: "create" });
|
||||||
|
const fieldNames = (fields.values ?? []).map((f) => f.name);
|
||||||
|
console.log("File create-action fields:", fieldNames.join(", "));
|
||||||
|
const acceptsContent = fieldNames.includes("content");
|
||||||
|
console.log(`Accepts 'content' param: ${acceptsContent ? "yes ✓" : "NO -- must POST file differently"}`);
|
||||||
|
|
||||||
|
if (!acceptsContent) {
|
||||||
|
console.log(
|
||||||
|
"\nFile.create on this Civi doesn't accept inline content. The upload path",
|
||||||
|
"needs to use a different mechanism (likely the legacy APIv3 Attachment.create",
|
||||||
|
"or the civicrm/upload endpoint). Stopping spike to avoid guessing.",
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Q2: create a File row.
|
||||||
|
divider("Q2: File.create with base64 content");
|
||||||
|
const created = await civi("File", "create", {
|
||||||
|
values: {
|
||||||
|
file_name: TEST_FILENAME,
|
||||||
|
mime_type: TEST_MIME,
|
||||||
|
content: Buffer.from(TEST_BODY, "utf8").toString("base64"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fileId = created.values?.[0]?.id;
|
||||||
|
console.log("File.create result:", JSON.stringify(created, null, 2));
|
||||||
|
if (!fileId) {
|
||||||
|
console.log("RESULT: failed — no id returned");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
console.log(`RESULT: file id = ${fileId} ✓`);
|
||||||
|
|
||||||
|
// ── Q3: read current value, write file id to custom field, read back.
|
||||||
|
divider(`Q3: Contact.update ${CUSTOM_FIELD} = ${fileId}`);
|
||||||
|
const before = await civi("Contact", "get", {
|
||||||
|
select: ["id", CUSTOM_FIELD, `${CUSTOM_FIELD}.file_name`],
|
||||||
|
where: [["id", "=", ORG_ID]],
|
||||||
|
});
|
||||||
|
const priorRow = before.values?.[0] ?? {};
|
||||||
|
const priorValue = priorRow[CUSTOM_FIELD] ?? null;
|
||||||
|
console.log("Prior value on contact:", JSON.stringify(priorRow, null, 2));
|
||||||
|
|
||||||
|
let writeOk = false;
|
||||||
|
try {
|
||||||
|
const upd = await civi("Contact", "update", {
|
||||||
|
where: [["id", "=", ORG_ID]],
|
||||||
|
values: { [CUSTOM_FIELD]: fileId },
|
||||||
|
});
|
||||||
|
console.log("Update result:", JSON.stringify(upd, null, 2));
|
||||||
|
writeOk = true;
|
||||||
|
console.log("RESULT: write OK ✓");
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Update failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Q4: read back through the join /api/data uses.
|
||||||
|
if (writeOk) {
|
||||||
|
divider("Q4: read-back via Contact.get + .file_name join");
|
||||||
|
const after = await civi("Contact", "get", {
|
||||||
|
select: ["id", "display_name", CUSTOM_FIELD, `${CUSTOM_FIELD}.file_name`],
|
||||||
|
where: [["id", "=", ORG_ID]],
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(after, null, 2));
|
||||||
|
const row = after.values?.[0];
|
||||||
|
if (row && Number(row[CUSTOM_FIELD]) === Number(fileId) && row[`${CUSTOM_FIELD}.file_name`]) {
|
||||||
|
console.log("RESULT: round-trip OK ✓ — pipeline is viable");
|
||||||
|
} else {
|
||||||
|
console.log("RESULT: read-back incomplete or id mismatch — see payload");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup: restore prior value, delete file row.
|
||||||
|
if (KEEP) {
|
||||||
|
console.log(`\n--keep set; leaving file id=${fileId} and contact pointing at it.`);
|
||||||
|
} else {
|
||||||
|
divider("Cleanup: restore prior contact value + File.delete");
|
||||||
|
try {
|
||||||
|
await civi("Contact", "update", {
|
||||||
|
where: [["id", "=", ORG_ID]],
|
||||||
|
values: { [CUSTOM_FIELD]: priorValue },
|
||||||
|
});
|
||||||
|
console.log(`Restored ${CUSTOM_FIELD} = ${priorValue}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Restore failed:", err.message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const del = await civi("File", "delete", { where: [["id", "=", fileId]] });
|
||||||
|
console.log(`File.delete id=${fileId}:`, JSON.stringify(del));
|
||||||
|
console.log("Cleanup OK ✓");
|
||||||
|
} catch (err) {
|
||||||
|
console.log("File.delete failed:", err.message);
|
||||||
|
console.log(`*** MANUAL CLEANUP NEEDED: File id=${fileId} is orphaned in CiviCRM ***`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nSpike complete.");
|
||||||
Reference in New Issue
Block a user