import { z } from "zod"; import { requireUser } from "~/features/auth/core/user.server"; import { imageFieldValueToImgId } from "~/features/img-upload/image-field.server"; import { formDataToObject } from "~/utils/remix.server"; import { formRegistry } from "./fields"; import type { ImageFieldValue } from "./image-field"; import { buildFieldPath } from "./utils"; export type ParseResult = | { success: true; data: T } | { success: false; fieldErrors: Record }; /** * Maps a {@link z.ZodError} to field-level errors keyed by form field name * (e.g. `members[0].userId`), keeping the first error per field. */ function fieldErrorsFromZodError(error: z.ZodError): Record { const fieldErrors: Record = {}; for (const issue of error.issues) { const path = buildFieldPath(issue.path); if (path && !fieldErrors[path]) { fieldErrors[path] = issue.message; } } return fieldErrors; } /** * Parses request body against a Zod schema. * Handles both JSON (SendouForm) and form data (FormWithConfirm) based on Content-Type. * Returns parsed data on success, or field-level errors on validation failure. */ export async function parseFormData({ request, schema, }: { request: Request; schema: T; }): Promise>> { const data = request.headers.get("Content-Type") === "application/json" ? await request.json() : formDataToObject(await request.formData()); const result = await schema.safeParseAsync(data); if (result.success) { return { success: true, data: result.data }; } return { success: false, fieldErrors: fieldErrorsFromZodError(result.error) }; } /** Image field values collapse to their stored id; everything else passes through. */ type ResolvedImages = T extends unknown ? { [K in keyof T]: T[K] extends ImageFieldValue ? number | null : T[K] } : never; /** * Like {@link parseFormData}, but additionally resolves every `image()` field in the schema to the * image id to store on the consuming FK column (`number | null`) via {@link imageFieldValueToImgId} * — uploading newly picked images, keeping unchanged ones, and clearing removed ones. The schema * may be a single object or a union of objects (e.g. an `_action` discriminated form). The * consuming action receives a plain id per image field and only writes it to its own entity. */ export async function parseFormDataWithImages({ request, schema, }: { request: Request; schema: T; }): Promise>>> { const result = await parseFormData({ request, schema }); if (!result.success) return result; const user = requireUser(); const data = { ...(result.data as Record) }; for (const { key, autoValidate } of imageFields(schema)) { if (key in data) { data[key] = await imageFieldValueToImgId({ value: data[key] as ImageFieldValue, user, autoValidate, }); } } return { success: true, data: data as ResolvedImages> }; } /** * Collects every `image()` field across a schema object or union of objects, along with each * field's `autoValidate` flag (whether its uploads bypass the moderator queue). */ function imageFields( schema: z.ZodTypeAny, ): Array<{ key: string; autoValidate: boolean }> { const objects = schema instanceof z.ZodUnion ? (schema.options as z.ZodObject[]) : schema instanceof z.ZodObject ? [schema] : []; const fields = new Map(); for (const object of objects) { for (const [key, fieldSchema] of Object.entries(object.shape)) { const meta = formRegistry.get(fieldSchema); if (meta?.type === "image") { fields.set(key, meta.autoValidate ?? false); } } } return [...fields].map(([key, autoValidate]) => ({ key, autoValidate })); }