"use client"; /** * use-forms — wire DTOs + TanStack Query hooks for public forms. * * A Form belongs to a database (submissions create records there), has a * unique `slug` (public URL `/f/:slug`), a status and an ordered list of * questions. There are NO per-question routes: questions are managed inline — * create/update send the WHOLE questions array (the server deletes + rebuilds * in the given order, regenerating fractional positions). * * Endpoints (verified against apps/api/src/modules/forms/*, no core formApi): * GET /databases/:databaseId/forms (list for a database → { data: […] }) * POST /databases/:databaseId/forms (create — questions optional) * GET /forms/:id (auth, full form + questions + properties) * PATCH /forms/:id (update; questions? rebuilds) * DELETE /forms/:id * GET /public/forms/:slug (public, PUBLISHED only → { form, questions }) * POST /public/forms/:slug/submit (public submit — { answers }) * * The public payload's `status` is LOWERCASE ("published"/"closed" — the * TASK-0107 whitelist deliberately does not echo the internal enum); the * in-app payload is UPPERCASE (DRAFT/PUBLISHED/CLOSED). Both are normalized * to the UPPERCASE FormStatus here so consumers (the renderer) handle one * shape. The public payload carries NO form id (leakage guarantee) — a * stable slug-keyed id is synthesized for React keys. * * Wire enums are UPPERCASE (Prisma schema + API zod + architecture doc agree): * question type: SHORT_TEXT | LONG_TEXT | NUMBER | EMAIL | DATE | CHECKBOX * | SINGLE_SELECT | MULTI_SELECT | RATING | FILE * status: DRAFT | PUBLISHED | CLOSED * * select/rating options + bounds live in the question's `validation` object * (options → `validation.options`; min/max → `validation.min/max`). */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; // --------------------------------------------------------------------------- // Wire enums + types // --------------------------------------------------------------------------- export const FORM_QUESTION_TYPES = [ "SHORT_TEXT", "LONG_TEXT", "NUMBER", "EMAIL", "DATE", "CHECKBOX", "SINGLE_SELECT", "MULTI_SELECT", "RATING", "FILE" ] as const; export type FormQuestionType = (typeof FORM_QUESTION_TYPES)[number]; export const FORM_STATUSES = ["DRAFT", "PUBLISHED", "CLOSED"] as const; export type FormStatus = (typeof FORM_STATUSES)[number]; export interface FormOption { /** Optional stable id; the display value (what's submitted) is `name`. */ id?: string; name: string; color?: string; } /** `validation` carries per-type extras: options (selects), min/max (number/rating/text). */ export interface FormValidation extends Record { /** * Select options. THE WIRE SHAPE IS A STRING ARRAY — the server validates * `SINGLE_SELECT`/`MULTI_SELECT` against `options: string[]` * (submit-schema.ts filters non-strings) and the shared contract tests * `{ options: ["a","b"] }`. The web's display model is `FormOption` objects * (`{ name }`), so `questionOptions` normalizes strings → `{ name: s }` on * read and the builder emits names (strings) on write. */ options?: (FormOption | string)[]; min?: number; max?: number; maxLength?: number; } /** What the client sends for a question (create/update). No id/position — * the server assigns both (position from array order). */ export interface FormQuestionInput { type: FormQuestionType; label: string; description?: string; required?: boolean; propertyId?: string | null; validation?: FormValidation | null; placeholder?: string; } /** A persisted question (getForm / public) = input + server-assigned id/position. */ export interface FormQuestion extends FormQuestionInput { id: string; position: string; required: boolean; } export interface FormConfig extends Record { /** Emoji (≤32) or a Phase-9 image file id (the public payload surfaces it as `iconUrl`). */ icon?: string | null; /** Markdown, ≤2000 chars — the public payload returns sanitized HTML here. */ intro?: string; /** One of FORM_ACCENTS (the builder + public CSS variable). */ accent?: string; successMessage?: string; closedMessage?: string; /** "Powered by AI Software Factory" footer (default true). */ poweredBy?: boolean; recaptcha?: { enabled?: boolean; siteKey?: string }; } /** Normalized form used by both the in-app (by id) and public (by slug) * surfaces — the two endpoints return different shapes, so both are folded * into this one. */ export interface FormDto { id: string; name: string; slug: string; description?: string | null; status: FormStatus; config: FormConfig; /** Auth shape only (publicGet returns databaseName instead). */ databaseId?: string; databaseName?: string; questions: FormQuestion[]; createdAt?: string; updatedAt?: string; } // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- function unwrap(body: unknown): T { const b = body as { data?: T } | null; if (b && typeof b === "object" && "data" in (b as Record)) { return (b as { data: T }).data; } return body as T; } /** * Normalize the wire status to the UPPERCASE FormStatus enum. The in-app * payload is already UPPERCASE (DRAFT/PUBLISHED/CLOSED); the public payload * is lowercase ("published"/"closed" — TASK-0107 whitelist). Unknown/absent * → "DRAFT" (the renderer treats non-PUBLISHED as a draft affordance). */ function normalizeStatus(status: string | undefined | null): FormStatus { const s = (status ?? "").toUpperCase(); return (FORM_STATUSES as readonly string[]).includes(s) ? (s as FormStatus) : "DRAFT"; } /** * Options for a select question (stored under validation.options). * * Normalizes the wire shape — a STRING array (the server's + shared's * contract) — into the web's `FormOption` objects, so `form-field.tsx`'s * `