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)
157 lines
5.5 KiB
TypeScript
157 lines
5.5 KiB
TypeScript
/**
|
|
* POST /api/submit
|
|
*
|
|
* Body: SubmitPayload { cid, cs, values }.
|
|
*
|
|
* Verifies checksum, resolves org from cid (mirror of /api/data), then
|
|
* creates a new `Check-in (organizing)` activity. The activity's own Stage
|
|
* field is intentionally left null: the form is not the authority on stage.
|
|
* Staff set Stage on their own check-in activities to mark transitions, and
|
|
* /api/data derives the org's current stage from the most recent stage-
|
|
* bearing activity.
|
|
*
|
|
* STUB MODE: if CiviCRM env vars are unset, returns success without writing.
|
|
*/
|
|
|
|
import { NextResponse } from "next/server";
|
|
import { civi, verifyChecksum } from "@/lib/civicrm";
|
|
import { allFields, ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
|
|
import type { SubmitPayload } from "@/types/form";
|
|
import { appEnv, redact } from "@/lib/env";
|
|
import { rateLimit, clientIp } from "@/lib/rate-limit";
|
|
|
|
function isStubMode(): boolean {
|
|
return !(
|
|
process.env.CIVI_BASE_URL &&
|
|
process.env.CIVI_API_KEY &&
|
|
process.env.CIVI_SITE_KEY
|
|
);
|
|
}
|
|
|
|
const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f]));
|
|
|
|
export async function POST(req: Request) {
|
|
// Rate limit per client IP. Generous default — 10 submissions per minute.
|
|
const ip = clientIp(req);
|
|
const rl = rateLimit(`submit:${ip}`, { capacity: 10, windowMs: 60_000 });
|
|
if (!rl.allowed) {
|
|
return NextResponse.json(
|
|
{ error: "Too many submissions. Please wait a moment and try again." },
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
"Retry-After": String(Math.ceil(rl.resetMs / 1000)),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
let body: SubmitPayload;
|
|
try {
|
|
body = (await req.json()) as SubmitPayload;
|
|
} catch {
|
|
return NextResponse.json({ error: "Malformed JSON body." }, { status: 400 });
|
|
}
|
|
const { cid, cs, values } = body;
|
|
if (!cid || !cs) {
|
|
return NextResponse.json({ error: "Missing cid or cs." }, { status: 400 });
|
|
}
|
|
|
|
if (isStubMode()) {
|
|
console.warn("[submit:STUB] would create Check-in (organizing) activity with values:", values);
|
|
return NextResponse.json({ ok: true, stub: true });
|
|
}
|
|
|
|
try {
|
|
return await runSubmit(cid, cs, values);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.error("[submit] failure:", redact(msg));
|
|
return NextResponse.json(
|
|
{
|
|
error: appEnv().isProduction
|
|
? "Could not save your survey. Please try again, or contact your engagement coordinator."
|
|
: `Save failed: ${redact(msg)}`,
|
|
},
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|
|
|
|
async function runSubmit(cid: string, cs: string, values: Record<string, unknown>): Promise<NextResponse> {
|
|
const ok = await verifyChecksum(cid, cs);
|
|
if (!ok) {
|
|
return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 });
|
|
}
|
|
|
|
// Resolve org via the relationship (mirror of /api/data).
|
|
const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", {
|
|
select: ["contact_id_b"],
|
|
where: [
|
|
["contact_id_a", "=", Number(cid)],
|
|
["relationship_type_id.name_a_b", "=", FORM_CONTACT_RELATIONSHIP],
|
|
["is_active", "=", true],
|
|
],
|
|
limit: 2,
|
|
});
|
|
const orgs = relRes.values ?? [];
|
|
if (orgs.length !== 1) {
|
|
return NextResponse.json(
|
|
{ error: "Could not resolve a unique organization for your contact." },
|
|
{ status: orgs.length === 0 ? 404 : 409 },
|
|
);
|
|
}
|
|
const orgId = orgs[0].contact_id_b;
|
|
|
|
// Split incoming values into:
|
|
// - activityRecord: fields with civiField → written to the new activity
|
|
// - orgContactValues: fields with civiContactField → written to the org
|
|
// contact via Contact.update
|
|
// Readonly fields are skipped entirely (display-only).
|
|
const activityRecord: Record<string, unknown> = {
|
|
"activity_type_id:name": ACTIVITY_TYPE_NAME,
|
|
"status_id:name": "Completed",
|
|
target_contact_id: orgId,
|
|
source_contact_id: Number(cid),
|
|
subject: "Co-op Survey (form submission)",
|
|
};
|
|
const orgContactValues: Record<string, unknown> = {};
|
|
for (const [name, value] of Object.entries(values)) {
|
|
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] = civiValue;
|
|
} else if (field.civiField) {
|
|
activityRecord[field.civiField] = civiValue;
|
|
}
|
|
}
|
|
|
|
// Update the org contact first (if any contact-bound fields changed),
|
|
// then create the submission activity. Order matters: if the contact
|
|
// update fails we'd rather not have an orphan activity claiming the
|
|
// submission succeeded.
|
|
if (Object.keys(orgContactValues).length > 0) {
|
|
await civi("Contact", "update", {
|
|
where: [["id", "=", orgId]],
|
|
values: orgContactValues,
|
|
});
|
|
}
|
|
|
|
await civi("Activity", "create", { values: activityRecord });
|
|
|
|
return NextResponse.json({ ok: true });
|
|
}
|