@nextlyhq/plugin-form-builder 0.0.2-alpha.66 → 0.0.2-alpha.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/plugin.ts","../src/collections/forms.ts","../src/utils/redirect-target.ts","../src/collections/access-defaults.ts","../src/collections/submissions.ts","../src/document-shapes.ts","../src/handlers/prepare-submission.ts","../src/utils/generate-schema.ts","../src/handlers/spam-detection.ts","../src/routes/public-forms.ts","../src/handlers/submit-form.ts","../src/config/validate-form.ts","../src/config/field-helpers.ts","../src/handlers/webhooks.ts","../src/index.ts"],"sourcesContent":["/**\n * Form Builder Plugin\n *\n * Visual form builder with drag-and-drop UI, submission management,\n * email notifications, and spam protection.\n *\n * @module plugin\n * @since 0.1.0\n */\n\nimport { createRequire } from \"node:module\";\n\nimport {\n definePlugin,\n NextlyError,\n type PluginDefinition,\n} from \"@nextlyhq/plugin-sdk\";\nimport type { CollectionConfig } from \"nextly\";\n// Author against the SDK — the stable, experimental plugin boundary.\n\nimport { formsCollection } from \"./collections/forms\";\nimport { submissionsCollection } from \"./collections/submissions\";\nimport {\n asFormDocument,\n asSubmissionDocument,\n asSubmissionDocuments,\n} from \"./document-shapes\";\nimport {\n sameSubmittedPayload,\n submissionMarks,\n type SubmissionOriginMarks,\n prepareSubmission,\n} from \"./handlers/prepare-submission\";\nimport { checkSpam, type SpamCheckResult } from \"./handlers/spam-detection\";\nimport { publicFormRoutes } from \"./routes/public-forms\";\nimport type {\n AnyFormField,\n BeforeEmailFilterContext,\n FormNotification,\n FormBuilderPluginOptions,\n FormEmailNotification,\n ResolvedFormBuilderConfig,\n} from \"./types\";\nimport { evaluateSingleCondition } from \"./utils/evaluate-conditions\";\nimport { exportToCSV, generateExportFilename } from \"./utils/export-formats\";\nimport { parseFieldRef } from \"./utils/field-references\";\nimport { normalizeRedirectRelationships } from \"./utils/redirect-target\";\n\nexport type NextlyPlugin = PluginDefinition;\n\n// Read the version from package.json so it can never drift from the published\n// package. Node/config-side only (this module is not part of the admin bundle).\nconst { version: PLUGIN_VERSION } = createRequire(import.meta.url)(\n \"../package.json\"\n) as { version: string };\n\n/** The runtime instance passed to plugin hooks (type extracted from `init`'s parameter). */\ntype NextlyInstance = Parameters<NonNullable<NextlyPlugin[\"init\"]>>[0];\n\n/** Internal augmentation: we stash the resolved config on the nextly instance for later retrieval. */\ntype NextlyWithFormBuilderConfig = NextlyInstance & {\n __formBuilderConfig?: ResolvedFormBuilderConfig;\n};\n\n// ---------------------------------------------------------------------------\n// Configuration resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Merge user options with sensible defaults.\n */\nfunction resolveConfig(\n options: FormBuilderPluginOptions\n): ResolvedFormBuilderConfig {\n const formOverrides = options.formOverrides || {};\n const submissionOverrides = options.formSubmissionOverrides || {};\n\n return {\n formOverrides: {\n ...formOverrides,\n slug: formOverrides.slug || \"forms\",\n labels: {\n singular: formOverrides.labels?.singular || \"Form\",\n plural: formOverrides.labels?.plural || \"Forms\",\n },\n },\n\n formSubmissionOverrides: {\n ...submissionOverrides,\n slug: submissionOverrides.slug || \"form-submissions\",\n labels: {\n singular: submissionOverrides.labels?.singular || \"Submission\",\n plural: submissionOverrides.labels?.plural || \"Submissions\",\n },\n },\n\n fields: {\n text: options.fields?.text ?? true,\n email: options.fields?.email ?? true,\n number: options.fields?.number ?? true,\n phone: options.fields?.phone ?? true,\n url: options.fields?.url ?? true,\n textarea: options.fields?.textarea ?? true,\n select: options.fields?.select ?? true,\n checkbox: options.fields?.checkbox ?? true,\n radio: options.fields?.radio ?? true,\n file: options.fields?.file ?? true,\n date: options.fields?.date ?? true,\n time: options.fields?.time ?? true,\n hidden: options.fields?.hidden ?? true,\n },\n\n redirectRelationships: normalizeRedirectRelationships(\n options.redirectRelationships\n ),\n // Filled at `init` from each collection's own metadata. Empty until then,\n // and a slug that stays absent reads as unknown rather than as not\n // localized — see `ResolvedFormBuilderConfig`.\n redirectTargetLocalization: {},\n beforeEmail: options.beforeEmail,\n\n notifications: {\n defaultFrom: options.notifications?.defaultFrom,\n defaultToEmail: options.notifications?.defaultToEmail,\n enabled: options.notifications?.enabled ?? true,\n },\n\n spamProtection: {\n honeypot: options.spamProtection?.honeypot ?? true,\n recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },\n rateLimit: {\n maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,\n windowMs: options.spamProtection?.rateLimit?.windowMs ?? 60_000,\n },\n },\n\n uploads: {\n maxFileSize: options.uploads?.maxFileSize ?? 10_485_760, // 10 MB\n allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [\n \"image/*\",\n \"application/pdf\",\n \"text/*\",\n ],\n uploadCollection: options.uploads?.uploadCollection ?? \"media\",\n },\n\n features: {\n builder: options.features?.builder ?? true,\n conditionalLogic: options.features?.conditionalLogic ?? true,\n fileUploads: options.features?.fileUploads ?? true,\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport interface FormBuilderPluginResult {\n /** Plugin definition — pass to `defineConfig({ plugins: [...] })`. */\n plugin: NextlyPlugin;\n /** Forms & Submissions collections (also auto-added by the plugin). */\n collections: CollectionConfig[];\n /** Resolved configuration with all defaults applied. */\n config: ResolvedFormBuilderConfig;\n}\n\n/**\n * Create a Form Builder plugin instance.\n *\n * @example Basic usage\n * ```ts\n * import { defineConfig } from \"nextly/config\";\n * import { formBuilder } from \"@nextlyhq/plugin-form-builder\";\n *\n * const fb = formBuilder();\n *\n * export default defineConfig({\n * plugins: [fb.plugin],\n * });\n * ```\n *\n * @example With options\n * ```ts\n * const fb = formBuilder({\n * notifications: { defaultFrom: \"noreply@example.com\" },\n * spamProtection: { honeypot: true },\n * formOverrides: {\n * slug: \"contact-forms\",\n * labels: { singular: \"Contact Form\", plural: \"Contact Forms\" },\n * },\n * });\n * ```\n */\nexport function formBuilder(\n options: FormBuilderPluginOptions = {}\n): FormBuilderPluginResult {\n const resolvedConfig = resolveConfig(options);\n\n const formsCol = formsCollection(resolvedConfig);\n const submissionsCol = submissionsCollection(resolvedConfig);\n\n const plugin = definePlugin({\n name: \"@nextlyhq/plugin-form-builder\",\n version: PLUGIN_VERSION,\n nextly: \">=0.0.2-alpha.21\",\n // Identity metadata for the admin plugins page, mirroring package.json.\n author: \"Nextly\",\n homepage: \"https://nextlyhq.com\",\n repository: \"https://github.com/nextlyhq/nextly\",\n license: \"MIT\",\n category: \"forms\",\n tags: [\"forms\", \"submissions\", \"email-notifications\"],\n\n // Declarative schema: the merged pipeline folds these into the\n // app schema — no manual `setup()` append needed. Just register the plugin.\n contributes: {\n collections: [formsCol, submissionsCol],\n // Custom permission — gates submission export beyond CRUD. The\n // canonical example third-party plugin authors copy.\n permissions: [\n {\n action: \"export\",\n resource: \"submissions\",\n label: \"Export Submissions\",\n description: \"Export form submissions as CSV/JSON\",\n // No `group`: the admin already files this under the plugin that\n // declared it, so naming the plugin again would nest it inside\n // itself. `group` is for a plugin with enough permissions to sort\n // its own into headings, which one is not.\n //\n // `danger` because the point of the permission is to take\n // submissions — names, emails, whatever a form asked for — out of\n // the site in a file.\n danger: true,\n },\n ],\n // HTTP route — exported at\n // /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by\n // default: gated by the custom `export-submissions` permission. Reads\n // via the secure-by-default service path as the authed user and\n // resolves its OWN slug through `ctx.self`. The canonical\n // contributes.routes example for third-party authors.\n routes: [\n // The public form endpoints, at the addresses core used to serve\n // before this plugin took them over. Root-mounted, so no caller's URL\n // changes; see `routes/public-forms`.\n ...publicFormRoutes(resolvedConfig),\n {\n method: \"GET\",\n path: \"/submissions/export\",\n requiredPermission: \"export-submissions\",\n handler: async (req, ctx) => {\n const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;\n const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;\n const url = new URL(req.url);\n const format = url.searchParams.get(\"format\") ?? \"json\";\n const formId = url.searchParams.get(\"form\");\n const status = url.searchParams.get(\"status\");\n\n // Spam stays out of exports unless it is explicitly requested —\n // an export is \"what people submitted\", not \"what bots sent\".\n const where: Record<string, unknown> = {};\n if (formId) where.form = { equals: formId };\n if (status) where.status = { equals: status };\n else where.status = { not_equals: \"spam\" };\n\n const opts = { as: \"user\", user: ctx.user ?? undefined } as const;\n\n // CSV needs its form BEFORE any submissions are read: the\n // columns come from one form's fields, and a malformed request\n // must fail fast instead of paginating the whole table first.\n let form: Record<string, unknown> | undefined;\n if (format === \"csv\") {\n if (!formId) {\n return Response.json(\n {\n error: {\n code: \"VALIDATION_ERROR\",\n message: \"CSV export requires a form parameter.\",\n },\n },\n { status: 400 }\n );\n }\n const formsSlugDeclared = resolvedConfig.formOverrides.slug;\n const formsSlug =\n ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;\n const formResult = await ctx.services.collections.listEntries(\n formsSlug,\n { where: { id: { equals: formId } } },\n opts\n );\n form = formResult.data[0];\n if (!form) {\n return Response.json(\n { error: { code: \"NOT_FOUND\", message: \"Form not found.\" } },\n { status: 404 }\n );\n }\n }\n\n // Page through the export with a hard ceiling: an unbounded loop\n // over a high-traffic form could hold the whole table in memory.\n // Hitting the ceiling is reported in a header, never silent.\n const MAX_EXPORT_ROWS = 50_000;\n const items: unknown[] = [];\n const pageSize = 200;\n let truncated = false;\n for (let page = 1; ; page += 1) {\n const result = await ctx.services.collections.listEntries(\n slug,\n { where, pagination: { limit: pageSize, page } },\n opts\n );\n items.push(...result.data);\n const pageWasFull = result.data.length === pageSize;\n if (items.length >= MAX_EXPORT_ROWS) {\n truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;\n items.length = MAX_EXPORT_ROWS;\n break;\n }\n if (!pageWasFull) break;\n }\n const truncationHeaders: Record<string, string> = truncated\n ? { \"X-Export-Truncated\": \"true\" }\n : {};\n\n if (format !== \"csv\" || !form) {\n return Response.json({ items }, { headers: truncationHeaders });\n }\n\n // The service returns parsed entries; the export helpers declare\n // the document shapes — the boundary cast is through unknown.\n const csv = exportToCSV(\n asSubmissionDocuments(items),\n asFormDocument(form)\n );\n const filename = generateExportFilename(\n typeof form.slug === \"string\" ? form.slug : \"form\",\n \"csv\"\n );\n return new Response(csv, {\n headers: {\n \"Content-Type\": \"text/csv; charset=utf-8\",\n \"Content-Disposition\": `attachment; filename=\"${filename}\"`,\n ...truncationHeaders,\n },\n });\n },\n },\n {\n // The builder UI reads the host's resolved field enable/disable map\n // from here, so the plugin option actually gates the type picker.\n // Options resolve server-side only; this is the one channel the\n // admin client has to them.\n method: \"GET\",\n path: \"/builder-config\",\n // Derived from the forms slug like the menu entry: with an\n // overridden slug, users hold read-<slug>, not read-forms.\n requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,\n handler: (_req, ctx) =>\n Promise.resolve(\n Response.json({\n fields: resolvedConfig.fields,\n // Notification defaults the builder surfaces: defaultToEmail\n // seeds a new form's first rule, defaultFrom renders as the\n // inherited sender placeholder.\n notifications: {\n defaultFrom: resolvedConfig.notifications.defaultFrom,\n defaultToEmail: resolvedConfig.notifications.defaultToEmail,\n },\n // Spam defaults the Settings tab surfaces, so per-form\n // override selects can show what \"inherit\" resolves to.\n spamProtection: {\n honeypot: resolvedConfig.spamProtection.honeypot,\n recaptchaEnabled:\n resolvedConfig.spamProtection.recaptcha?.enabled ?? false,\n },\n // Runtime-resolved collection slugs (through ctx.self, so a\n // framework .rename() is honored too) — admin components\n // never hardcode \"forms\"/\"form-submissions\".\n // Names only. A pattern may be a function, which does not\n // survive JSON and which the browser has no use for — the\n // admin picks a document, the server builds the URL.\n redirectCollections: Object.keys(\n resolvedConfig.redirectRelationships\n ),\n slugs: {\n forms:\n ctx.self.collections[resolvedConfig.formOverrides.slug] ??\n resolvedConfig.formOverrides.slug,\n submissions:\n ctx.self.collections[\n resolvedConfig.formSubmissionOverrides.slug\n ] ?? resolvedConfig.formSubmissionOverrides.slug,\n },\n })\n ),\n },\n ],\n },\n\n // Forms is a first-class destination, not a plugin detail: standalone\n // placement gives it its own main-rail icon after Media, and its\n // sub-sidebar lists the plugin's collections (Forms, Submissions) —\n // no separate menu contribution, so \"Forms\" exists exactly once.\n // Hosts preferring the Plugins section override placement in config.\n // (The old admin.settings.component that rendered a second full builder\n // at /admin/plugins/<slug> is gone; the collection Edit-view override\n // is the single FormBuilderView mount.)\n admin: {\n placement: \"standalone\",\n after: \"media\",\n order: 50,\n appearance: { icon: \"FileText\", label: \"Forms\" },\n description: \"Create and manage forms with submission tracking\",\n },\n\n // -- Init ----------------------------------------------------------------\n // Registers an afterCreate hook on submissions to send email notifications.\n async init(nextly: NextlyInstance) {\n // Resolve our OWN submissions slug through ctx.self, so the hook\n // follows a framework `.rename()` as well as our formSubmissionOverrides\n // option. The declared slug is the key; ctx.self maps it to the resolved\n // (possibly renamed) slug. Identity when not renamed.\n const declaredSubmissionsSlug =\n resolvedConfig.formSubmissionOverrides.slug;\n const submissionSlug =\n nextly.self.collections[declaredSubmissionsSlug] ??\n declaredSubmissionsSlug;\n\n // Hook/event subscriptions are idempotent across HMR — the platform\n // clears a plugin's prior subscriptions before re-init (B2), so the old\n // globalThis dedup guard is no longer needed.\n nextly.logger.info(\"Form Builder plugin initialized\", {\n formsCollection: resolvedConfig.formOverrides.slug,\n submissionsCollection: submissionSlug,\n enabledFields: Object.entries(resolvedConfig.fields)\n .filter(([, on]) => on)\n .map(([name]) => name),\n });\n\n // Ask each redirect target collection whether it is localized. The save\n // rule and the submission path both need it and neither can reach the\n // registry themselves: a collection hook is handed `req.nextly`, which\n // is the Direct API and carries no metadata accessor. Resolved once here\n // and written onto the config both of them already close over.\n //\n // Per collection rather than in one call: a slug that cannot be read\n // must stay ABSENT from the map, and a single failing lookup would\n // otherwise take its readable siblings with it.\n // Emptied first. `init` runs again on HMR and re-registration against the\n // SAME config object, so a lookup that succeeded on the previous boot and\n // fails on this one would leave its old answer standing while the catch\n // below believes it left the slug absent. A stale `false` then reads a\n // localized target as unreachable and drops its redirect; a stale `true`\n // lets a plain draft target through.\n for (const slug of Object.keys(\n resolvedConfig.redirectTargetLocalization\n )) {\n delete resolvedConfig.redirectTargetLocalization[slug];\n }\n\n // AWAITED, not fired and forgotten: a write reaching the save rule before\n // this resolves would find the map empty and read every target as\n // undecided, which is the refusal silently not happening.\n await Promise.all(\n Object.keys(resolvedConfig.redirectRelationships).map(\n async declared => {\n const slug = nextly.self.collections[declared] ?? declared;\n try {\n // `getCollection` reads registered metadata and does not consult\n // the context — its parameter is declared for a future ACL hook\n // and is unused today — so an empty context is the honest\n // argument rather than a fabricated user.\n const collection =\n await nextly.services.collections.getCollection(slug, {});\n const localized = (collection as { localized?: unknown })\n ?.localized;\n if (typeof localized === \"boolean\") {\n resolvedConfig.redirectTargetLocalization[declared] = localized;\n }\n } catch {\n // Left absent on purpose: unknown, not \"not localized\".\n nextly.logger.debug?.(\n \"Redirect target localization could not be read\",\n { collection: slug }\n );\n }\n }\n )\n );\n\n (nextly as NextlyWithFormBuilderConfig).__formBuilderConfig =\n resolvedConfig;\n\n // D63: run the user's beforeEmail config as a filter on the form-builder seam.\n if (resolvedConfig.beforeEmail) {\n nextly.filters.add(\n \"form-builder.beforeEmail\",\n (emails: FormEmailNotification[], ctx: BeforeEmailFilterContext) =>\n resolvedConfig.beforeEmail!({\n emails,\n // Boundary: loose runtime documents → the user's typed contract.\n form: asFormDocument(ctx.form),\n submission: asSubmissionDocument(ctx.submission),\n })\n );\n }\n\n // Every submission is brought to the form's own shape here, whatever\n // created it. Registered directly on the registry, like the two below, so\n // it runs for every API surface that writes one: the plugin's HTTP\n // handler, `nextly.forms.submit()`, and an admin creating a row by hand.\n //\n // The Direct API did none of this. A submission arriving that way was\n // stored with whatever keys the caller sent, values that never met the\n // form's schema, and markup intact, while the same submission over HTTP\n // was transformed, validated and sanitized. Putting the rule at the write\n // seam rather than in the second caller is what stops a third one\n // inheriting the gap.\n // The forms slug, resolved the way the submissions one above is. A host\n // that renames a contributed collection takes the declared slug out of\n // the registry, so reading the parent form by `formOverrides.slug` finds\n // nothing, and this hook would then refuse every submission on a renamed\n // install. Resolved once here and used by both readers.\n const declaredFormsSlug = resolvedConfig.formOverrides.slug;\n const formsSlug =\n nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;\n\n // `beforeChange` rather than `beforeCreate`: it is the last\n // COLLECTION-level mutating phase before the insert. Core runs stored\n // `beforeCreate` hooks after the code-registered ones, so a host hook\n // could put undeclared keys or markup back into a payload a\n // `beforeCreate` check had just passed.\n //\n // Field-level `beforeChange` hooks run after this one and are handed the\n // whole record, so a host that registers one on this collection can still\n // reshape `data` afterwards. No collection phase follows them to register\n // on, and core's own sanitizer sits earlier still, on `beforeCreate`.\n // This is the latest point a plugin can check from, which is not the same\n // as a point nothing can follow.\n nextly.hooks.on(\n \"beforeChange\",\n submissionSlug,\n async (context: unknown) =>\n prepareSubmissionForWrite(context, formsSlug, nextly)\n );\n\n // Register afterCreate hook for email notifications\n nextly.hooks.on(\n \"afterCreate\",\n submissionSlug,\n async (context: unknown) => {\n await handleSubmissionCreated(context, resolvedConfig, nextly);\n }\n );\n\n // Stamp admin edits of submitted data: changing what the visitor\n // submitted must leave a visible trace. Registered directly on the\n // registry (like the notification hook) so it runs for every API\n // surface that updates a submission.\n // Reads the patch as it arrived. Core runs this phase before\n // `beforeChange`, so the payload the write seam adds to a status-only\n // patch is not here yet and cannot be mistaken for an admin's edit.\n nextly.hooks.on(\"beforeUpdate\", submissionSlug, (context: unknown) => {\n const ctx = context as {\n data?: Record<string, unknown>;\n user?: { id?: string };\n };\n if (ctx.data && ctx.data.data !== undefined) {\n ctx.data.editedAt = new Date();\n ctx.data.editedBy = ctx.user?.id ?? null;\n }\n return ctx.data;\n });\n\n // Inject a real submissionCount into form reads (spam excluded — the\n // number answers \"how many people submitted\", not \"how many bots\").\n nextly.hooks.on(\"afterRead\", formsSlug, (context: unknown) =>\n injectSubmissionCount(context, submissionSlug, nextly)\n );\n },\n });\n\n return {\n plugin,\n collections: [formsCol, submissionsCol],\n config: resolvedConfig,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Utilities\n// ---------------------------------------------------------------------------\n\n/**\n * Retrieve the resolved Form Builder config from a Nextly plugin context.\n */\nexport function getFormBuilderConfig(\n nextly: unknown\n): ResolvedFormBuilderConfig | undefined {\n if (!nextly || typeof nextly !== \"object\") return undefined;\n return (nextly as Partial<NextlyWithFormBuilderConfig>).__formBuilderConfig;\n}\n\n/**\n * Resolve a `{{fieldName}}` template reference against submitted values.\n * Plain strings (e.g. email addresses) are returned as-is.\n */\nfunction resolveFieldRef(ref: string, data: Record<string, unknown>): string {\n // The pattern is shared with the admin, which decides whether a value is a\n // reference at all. Written twice, a value the editor accepted and this did\n // not would be delivered with its braces intact.\n const name = parseFieldRef(ref);\n if (name === null) return ref;\n const value = data[name];\n return typeof value === \"string\" ? value : \"\";\n}\n\n// ---------------------------------------------------------------------------\n// Attachment collection for file fields\n// ---------------------------------------------------------------------------\n\n/**\n * Scan form fields for file-upload fields marked `attachToEmail` and\n * collect the corresponding mediaIds from the submitted data. Returns\n * an array of `{ mediaId }` objects ready to pass as `attachments` to\n * `emailService.sendWithTemplate()`.\n *\n * Skips empty/missing values silently (optional file fields). Handles\n * both single-value and `multiple: true` file fields.\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function collectAttachmentInputs(\n fields: Array<Record<string, unknown>>,\n submittedData: Record<string, unknown>\n): Array<{ mediaId: string }> {\n const attachments: Array<{ mediaId: string }> = [];\n\n for (const field of fields) {\n if (field.type !== \"file\" || !field.attachToEmail) continue;\n\n const name = field.name as string;\n const value = submittedData[name];\n if (value == null) continue;\n\n if (Array.isArray(value)) {\n for (const item of value) {\n if (typeof item === \"string\" && item) {\n attachments.push({ mediaId: item });\n }\n }\n } else if (typeof value === \"string\" && value) {\n attachments.push({ mediaId: value });\n }\n }\n\n return attachments;\n}\n\n// ---------------------------------------------------------------------------\n// Submission notification handler\n// ---------------------------------------------------------------------------\n\n/**\n * Normalize a JSON column value from a raw DB row into an object. Hook\n * contexts carry the row as stored, so the value may be an already-parsed\n * object (jsonb dialects) or a serialized string (text-storage dialects).\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function parseJsonColumn(value: unknown): Record<string, unknown> {\n if (typeof value === \"string\") {\n try {\n const parsed: unknown = JSON.parse(value);\n return parsed && typeof parsed === \"object\" && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {};\n } catch {\n return {};\n }\n }\n return value && typeof value === \"object\" && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {};\n}\n\n/** Trim an address list and drop blanks; undefined when nothing remains. */\nfunction normalizeAddressList(\n addresses: readonly string[] | undefined\n): string[] | undefined {\n if (!Array.isArray(addresses)) return undefined;\n const cleaned = addresses\n .map(address => (typeof address === \"string\" ? address.trim() : \"\"))\n .filter(Boolean);\n return cleaned.length > 0 ? cleaned : undefined;\n}\n\n/** Why a notification rule produced no email for a given submission. */\nexport interface SkippedNotification {\n notificationId: string;\n reason: \"empty-recipient\" | \"condition-unmet\" | \"no-template\";\n}\n\n/**\n * Resolve a form's notification rules against one submission into outgoing\n * email descriptors, honoring everything a rule can configure: the send\n * condition gates the rule, the recipient/reply-to resolve `{{fieldName}}`\n * references, and the sender falls back from the rule's own address to the\n * plugin's `notifications.defaultFrom` (undefined lets the template/provider\n * default apply downstream).\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function buildNotificationEmails(input: {\n notifications: readonly FormNotification[];\n submittedData: Record<string, unknown>;\n formName: unknown;\n submissionId: unknown;\n defaultFrom?: string;\n}): { emails: FormEmailNotification[]; skipped: SkippedNotification[] } {\n const { notifications, submittedData, formName, submissionId, defaultFrom } =\n input;\n\n const seen = new Set<string>();\n const emails: FormEmailNotification[] = [];\n const skipped: SkippedNotification[] = [];\n\n for (const notification of notifications) {\n if (!notification.enabled) continue;\n\n if (!notification.templateSlug) {\n skipped.push({ notificationId: notification.id, reason: \"no-template\" });\n continue;\n }\n\n // Deduplicate (UI can append duplicates on repeated saves). For id-less\n // rules (API-authored), only an exact structural duplicate counts — a\n // to+template key would silently drop distinct rules that share a\n // recipient and template but differ elsewhere (e.g. their condition).\n const key = notification.id || JSON.stringify(notification);\n if (seen.has(key)) continue;\n seen.add(key);\n\n // An unmet send condition skips the rule for this submission — an\n // expected state, never an error.\n if (\n notification.condition &&\n !evaluateSingleCondition(notification.condition, submittedData)\n ) {\n skipped.push({\n notificationId: notification.id,\n reason: \"condition-unmet\",\n });\n continue;\n }\n\n // Trim every address on the way out: whitespace-only values must count\n // as empty, and stray spaces must not reach the provider as headers.\n const to = (\n notification.recipientType === \"field\"\n ? resolveFieldRef(notification.to, submittedData)\n : notification.to\n ).trim();\n\n if (!to) {\n skipped.push({\n notificationId: notification.id,\n reason: \"empty-recipient\",\n });\n continue;\n }\n\n const cc = normalizeAddressList(notification.cc);\n const bcc = normalizeAddressList(notification.bcc);\n\n // Sender resolution: the rule's own address wins, then the plugin's\n // configured default; undefined defers to the template/provider chain.\n const from =\n notification.senderEmail?.trim() || defaultFrom?.trim() || undefined;\n\n // Reply-To resolves {{fieldName}} like recipients do; a reference to a\n // field the visitor left empty degrades to \"no Reply-To header\" rather\n // than an invalid address.\n const replyTo = notification.replyTo\n ? resolveFieldRef(notification.replyTo, submittedData).trim() || undefined\n : undefined;\n\n emails.push({\n to,\n templateSlug: notification.templateSlug,\n variables: {\n ...submittedData,\n formName,\n submissionId,\n },\n providerId: notification.providerId,\n from,\n replyTo,\n cc,\n bcc,\n notificationId: notification.id,\n });\n }\n\n return { emails, skipped };\n}\n\n/**\n * Send email notifications after a form submission is created.\n */\n/**\n * Bring an incoming submission to the shape its form declares.\n *\n * Runs on `beforeCreate` for the submissions collection, so it sees every write\n * rather than every caller. The cost is one read of the parent form per\n * submission: the HTTP handler has already loaded it and cannot hand it over,\n * because a hook receives the row and not the request. Submissions are written\n * one visitor at a time and the read is by primary key, which is the cheaper\n * side of the trade against a rule two callers have to remember.\n *\n * Refuses rather than storing when it cannot judge. A form it cannot read, or\n * one whose `fields` is not a list, leaves nothing to transform against, and\n * transforming against an empty list would project the submission down to `{}`\n * and store a row that says the visitor sent nothing. Silently emptying someone's\n * submission is worse than declining it.\n *\n * A row that names no form is passed through untouched. `form` is `required` on\n * the collection, so it is already refused a step later, and rejecting it here\n * would answer a question this hook was not asked.\n */\n/**\n * The parent form's id, however the row spells the relationship.\n *\n * A relationship arrives as an id string from a write and as a populated object\n * from a read, and both hooks on this collection have to cope with either.\n * Stated once so they cannot come to disagree about what counts as a reference.\n */\nfunction parentFormId(submission: Record<string, unknown>): string | null {\n const raw = submission.form;\n if (typeof raw === \"string\") return raw;\n if (raw && typeof raw === \"object\") {\n const maybeId = (raw as { id?: unknown }).id;\n if (typeof maybeId === \"string\") return maybeId;\n }\n return null;\n}\n\n/**\n * The submitted payload, as an object, or a refusal.\n *\n * `data` arrives as an object from every caller in this repository. A string is\n * read as the JSON a dialect stores rather than assumed to be one field's\n * value, and anything else is refused: that column is `required`, so an omitted\n * payload is meant to be refused, and substituting `{}` satisfied the check on\n * the way past. A form whose fields are all optional then accepted the empty\n * object and the row was stored.\n */\nfunction submittedPayload(raw: unknown): Record<string, unknown> {\n if (typeof raw === \"string\") {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n // Falls through to the refusal below.\n }\n } else if (raw && typeof raw === \"object\" && !Array.isArray(raw)) {\n return raw as Record<string, unknown>;\n }\n throw NextlyError.validation({\n errors: [\n {\n path: \"data\",\n code: \"INVALID\",\n message: \"Submission data must be an object.\",\n },\n ],\n });\n}\n\n/**\n * Whether an update has to be brought back to the form's shape.\n *\n * Two kinds of update do, and one does not.\n *\n * A patch that replaces `data` is replacing what the visitor sent, so it is\n * checked exactly as the create was. Exempting every update meant a caller who\n * may edit a submission could put undeclared keys, values the form's schema\n * rejects and markup into the row a moment after the create had refused to\n * accept them.\n *\n * A patch that touches `form` moves the row under a different schema, and the\n * payload it already holds is what has to satisfy that one. Such a patch\n * carries no `data` of its own, so a rule keyed on `data` alone let a caller\n * move a submission onto any form and leave behind a payload the form it now\n * belongs to rejects.\n *\n * A patch that takes the row out of spam is the third. An evidence row was\n * stored WITHOUT being validated, deliberately, so that a false positive stays\n * reviewable. The admin's \"Not spam\" action sends only a status, and letting it\n * through unchecked turned a payload the form rejects into an ordinary counted\n * submission. Leaving spam is the moment that payload has to satisfy the form,\n * and a row that cannot has to be corrected in the same update.\n *\n * An update that does none of these leaves nothing to check: an admin changing\n * a status between two non-spam values sends only that, and preparing an absent\n * payload would store an empty submission over a real one. The last two are\n * left alone for the same reason when the stored row did not reach this hook,\n * since there is then no payload to judge.\n */\nfunction updateNeedsChecking(\n submission: Record<string, unknown>,\n stored: Record<string, unknown> | undefined\n): boolean {\n if (submission.data !== undefined) return true;\n // Everything below judges the STORED payload, so there has to be one.\n if (stored?.data === undefined) return false;\n if (submission.form !== undefined) return true;\n return (\n stored.status === \"spam\" &&\n submission.status !== undefined &&\n submission.status !== \"spam\"\n );\n}\n\nexport async function prepareSubmissionForWrite(\n context: unknown,\n formsSlug: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown> | undefined> {\n const ctx = context as {\n data?: Record<string, unknown>;\n operation?: string;\n originalData?: Record<string, unknown>;\n user?: { id?: string };\n req?: { http?: { ip: string | null; method: string } };\n };\n const submission = ctx.data;\n if (!submission || typeof submission !== \"object\") return ctx.data;\n const stored = ctx.originalData;\n\n // A patch need not repeat the relationship, so the stored row is what says\n // which form this payload has to match.\n const formId =\n parentFormId(submission) ?? (stored ? parentFormId(stored) : null);\n if (!formId) return ctx.data;\n\n if (ctx.operation !== \"create\" && !updateNeedsChecking(submission, stored)) {\n return ctx.data;\n }\n\n const incoming = submittedPayload(\n submission.data !== undefined ? submission.data : stored?.data\n );\n\n // Read off the row itself, so it describes this write and no other.\n const marks = submissionMarks(submission);\n\n const form = await formToCheckAgainst(marks, formsSlug, formId, nextly);\n const fields = form.fields as AnyFormField[];\n\n // Spam is judged HERE, at the seam every door passes through, rather than in\n // the route: the submissions collection grants public create, so the generic\n // collection create is a second public door and the Direct API a third. A\n // rule that lives in one of them guards one of them.\n //\n // Only when a request produced this write. `ctx.req.http` is absent for a\n // seed, an import or a job, and a rule aimed at a visitor must not judge a\n // server that is importing ten thousand rows as one.\n const spam =\n ctx.operation === \"create\" && ctx.req?.http\n ? await judgeSubmission(incoming, form, ctx.req.http, nextly)\n : undefined;\n\n if (spam?.reason === \"rate_limit\") {\n // Refused, not stored: a limiter that wrote a row per refusal would turn\n // volume into a database it fills for the attacker. The plugin's own route\n // answers its visitor with a success anyway, so a bot learns nothing from\n // it; the other doors are machine-facing and get the honest 429.\n throw NextlyError.rateLimited();\n }\n\n // Content spam keeps its evidence. The handler stores a honeypot or reCAPTCHA\n // hit flagged rather than dropping it, so a false positive stays recoverable,\n // and requiring it to be valid would throw away the thing being reviewed. It\n // is still transformed and sanitized.\n // Leniency is a fact about the CALL, not about the row. Reading it off\n // `status` made it caller-controlled: the collection grants public create and\n // nothing restricts that field, so anyone could post `status: \"spam\"` and\n // switch validation off for their own row.\n const prepared = prepareSubmission({\n data: incoming,\n fields,\n validate: marks?.keepAsEvidence !== true && spam === undefined,\n });\n\n if (prepared.validationErrors) {\n throw NextlyError.validation({\n errors: Object.entries(prepared.validationErrors).map(\n ([path, message]) => ({ path, code: \"INVALID\", message })\n ),\n });\n }\n\n ctx.data = { ...submission, data: prepared.data };\n if (spam) {\n // Flagged, never dropped: a false positive stays reviewable in the Spam\n // view and recoverable through \"Not spam\". Written here rather than trusted\n // from the payload, because the collection grants public create and nothing\n // restricts these fields, so a caller could otherwise mark its own row.\n ctx.data.status = \"spam\";\n ctx.data.spamReason = spam.reason ?? null;\n }\n if (ctx.operation !== \"create\" && submission.data === undefined) {\n stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);\n }\n return ctx.data;\n}\n\n/**\n * The fields a submission has to satisfy.\n *\n * The handler that already read this form hands it over rather than have the\n * write read it a second time. That read is not free: `findEntryById` runs the\n * forms collection's `afterRead` hooks, and this plugin registers one that\n * COUNTs the form's submissions, so a write was paying for a count of every\n * write before it. Only ever the form this row names, because the id has to\n * match.\n */\nasync function formToCheckAgainst(\n marks: SubmissionOriginMarks | undefined,\n formsSlug: string,\n formId: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown>> {\n const handedOver = marks?.form?.id === formId ? marks.form : null;\n const form = handedOver ?? (await fetchParentForm(formsSlug, formId, nextly));\n if (!form || !Array.isArray(form.fields)) {\n throw NextlyError.validation({\n errors: [\n {\n path: \"form\",\n code: \"INVALID\",\n message:\n \"The form this submission belongs to could not be read, so the submission could not be checked against it.\",\n },\n ],\n });\n }\n return form;\n}\n\n/**\n * The part of a rate-limit key that names the form.\n *\n * The ID, never the slug. A door that hands the seam the form it already read\n * carries `{ id, fields }`, and a door that makes the seam fetch it carries the\n * whole row: keyed on whatever each happens to have, one form gets one window\n * per door and a caller alternating between two doors spends two budgets.\n *\n * Only a string identifies a form. A row whose id is unusable is limited as its\n * own bucket rather than silently joining a neighbour's.\n */\nfunction rateLimitKeyFor(form: Record<string, unknown>): string {\n const { id } = form;\n return typeof id === \"string\" && id.length > 0 ? id : \"unidentified-form\";\n}\n\n/**\n * Whether this submission looks like a bot's, and why.\n *\n * `undefined` means nothing was detected, which is not the same as \"no rule\n * ran\": a form that disables the honeypot and a deployment that configures no\n * rate limit both answer that way, and both mean the submission is stored as an\n * ordinary one.\n *\n * The address comes from the core, resolved under the deployment's proxy-trust\n * settings, so it is the closest untrusted hop rather than whatever the sender\n * put in `x-forwarded-for`. `null` means no address could be trusted, and the\n * rate limit does not run: keying it on a placeholder would put every\n * unidentifiable client in one window, where the first bot to fill it locks out\n * every visitor behind an untrusted proxy. The honeypot still applies, because\n * it reads the payload rather than the caller.\n */\nasync function judgeSubmission(\n payload: Record<string, unknown>,\n form: Record<string, unknown>,\n http: { ip: string | null; method: string },\n nextly: NextlyInstance\n): Promise<SpamCheckResult | undefined> {\n const config = getFormBuilderConfig(nextly);\n if (!config) return undefined;\n\n // The trap is set among the keys the form does NOT declare. Several honeypot\n // names are ones a real form might use -- `website`, `url_field` -- so\n // probing the whole payload would flag every submission to a form that\n // declares one, and flag it as bot traffic.\n const declared = new Set(\n (Array.isArray(form.fields) ? form.fields : [])\n .map(field => (field as { name?: unknown }).name)\n .filter((name): name is string => typeof name === \"string\")\n );\n const undeclared = Object.fromEntries(\n Object.entries(payload).filter(([key]) => !declared.has(key))\n );\n\n const settings = (form.settings ?? {}) as { honeypotEnabled?: boolean };\n const verdict = await checkSpam({\n data: undeclared,\n ipAddress: http.ip ?? undefined,\n formSlug: rateLimitKeyFor(form),\n config: {\n honeypot: settings.honeypotEnabled ?? config.spamProtection.honeypot,\n rateLimit: config.spamProtection.rateLimit,\n },\n });\n return verdict.isSpam ? verdict : undefined;\n}\n\n/**\n * Record a payload change this hook derived, since nothing else will.\n *\n * A patch that did not carry a payload but changed one is still an edit of what\n * the visitor sent: moving a submission to another form re-projects its answers\n * onto that form's fields and can drop one it does not declare. The stamp on\n * `beforeUpdate` has already run by then and only marks a patch that arrived\n * with `data`, so without this the stored answers change with nothing in the\n * row saying who changed them.\n */\nfunction stampDerivedEdit(\n row: Record<string, unknown>,\n user: { id?: string } | undefined,\n before: Record<string, unknown>,\n after: Record<string, unknown>\n): void {\n if (sameSubmittedPayload(before, after)) return;\n row.editedAt = new Date();\n row.editedBy = user?.id ?? null;\n}\n\nasync function handleSubmissionCreated(\n context: unknown,\n config: ResolvedFormBuilderConfig,\n nextly: NextlyInstance\n): Promise<void> {\n // The plugin-level kill switch: `notifications.enabled: false` turns off\n // all form emails regardless of per-rule state.\n if (!config.notifications.enabled) return;\n\n const submission = (context as { data?: Record<string, unknown> }).data;\n if (!submission) return;\n\n // Spam is stored for review, but nobody gets emailed about it — otherwise\n // every bot hit would trigger the form's notification rules.\n if (submission.status === \"spam\") return;\n\n const formId = parentFormId(submission);\n if (!formId) return;\n\n // Fetch the parent form\n const declaredFormsSlug = config.formOverrides.slug;\n const form = await fetchParentForm(\n nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug,\n formId,\n nextly\n );\n if (!form) return;\n\n const notifications = Array.isArray(form.notifications)\n ? (form.notifications as FormNotification[])\n : [];\n if (notifications.length === 0) return;\n\n const emailService = nextly.services.email;\n if (!emailService) return;\n\n // The afterCreate hook receives the raw DB row, so on dialects that store\n // JSON columns as text (e.g. SQLite) `data` arrives serialized. Without\n // parsing it, {{field}} recipients, reply-to references, and send\n // conditions all silently see an empty submission.\n const submittedData = parseJsonColumn(submission.data);\n\n // Collect mediaIds from file fields marked for email attachment\n const formFields = Array.isArray(form.fields)\n ? (form.fields as Array<Record<string, unknown>>)\n : [];\n const fileAttachments = collectAttachmentInputs(formFields, submittedData);\n\n // -- Build phase: resolve each enabled notification into an outgoing\n // descriptor (the value the D63 seam transforms).\n const { emails, skipped } = buildNotificationEmails({\n notifications,\n submittedData,\n formName: form.name,\n submissionId: submission.id,\n defaultFrom: config.notifications.defaultFrom,\n });\n\n for (const skip of skipped) {\n if (skip.reason === \"empty-recipient\") {\n nextly.logger.warn?.(\n \"Form Builder: empty recipient, skipping notification\",\n { notificationId: skip.notificationId, formSlug: form.slug }\n );\n } else {\n // Unmet conditions and missing templates are expected states, not\n // faults — surface them only at debug level.\n nextly.logger.debug?.(\"Form Builder: notification skipped\", {\n notificationId: skip.notificationId,\n formSlug: form.slug,\n reason: skip.reason,\n });\n }\n }\n\n if (emails.length === 0) return;\n\n // -- Seam: thread the outgoing notifications through the D63 filter so user\n // config (and any other registered handler) can modify/filter them.\n const finalEmails = await nextly.filters.apply<\n FormEmailNotification[],\n BeforeEmailFilterContext\n >(\"form-builder.beforeEmail\", emails, { form, submission });\n\n // -- Send phase: send the (possibly transformed) outgoing notifications.\n for (const email of finalEmails) {\n try {\n await emailService.sendWithTemplate(\n email.templateSlug,\n email.to,\n email.variables,\n {\n providerId: email.providerId,\n from: email.from,\n replyTo: email.replyTo,\n cc: email.cc,\n bcc: email.bcc,\n attachments: fileAttachments.length > 0 ? fileAttachments : undefined,\n }\n );\n\n nextly.logger.info?.(\"Form Builder: notification sent\", {\n formSlug: form.slug,\n to: email.to,\n templateSlug: email.templateSlug,\n notificationId: email.notificationId,\n attachmentCount: fileAttachments.length,\n });\n } catch (err) {\n nextly.logger.error?.(\"Form Builder: notification failed\", {\n to: email.to,\n templateSlug: email.templateSlug,\n notificationId: email.notificationId,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n}\n\n/**\n * Fetch the parent form document for a submission.\n */\n/**\n * Put a real `submissionCount` on every form a read returned.\n *\n * Spam is excluded, because the number answers \"how many people submitted\", not\n * \"how many bots\". `afterRead` fires for single reads and for list reads, so\n * both shapes are handled; the counts run concurrently, and form lists are\n * paginated, so this is a bounded fan-out of small indexed queries rather than\n * a serial walk.\n *\n * A read that asked for the schema alone is left alone. A submission write\n * reads its parent form only to check the payload against that form's fields,\n * and counting there is presentation work nobody on that path reads. It also\n * grows with the form's history, so every submission was paying for a count of\n * every submission before it.\n *\n * The flag decides how much work to do and nothing else. Forged, the worst it\n * can produce is a form read whose `submissionCount` is absent, and it cannot\n * come from a request body in any case: the hook context is set by the\n * server-side caller of the service.\n */\nexport async function injectSubmissionCount(\n context: unknown,\n submissionSlug: string,\n nextly: NextlyInstance\n): Promise<void> {\n const hook = context as {\n data?: unknown;\n context?: Record<string, unknown>;\n };\n if (hook.context?.[SCHEMA_ONLY_READ] === true) return;\n\n const records = Array.isArray(hook.data)\n ? hook.data\n : hook.data\n ? [hook.data]\n : [];\n await Promise.all(\n records.map(async record => {\n const form = record as Record<string, unknown>;\n if (typeof form.id !== \"string\") return;\n try {\n // Count as system: whoever may read the form may see its submission\n // volume without holding submission read rights.\n form.submissionCount = await nextly.services.collections.count(\n submissionSlug,\n {\n where: {\n form: { equals: form.id },\n status: { not_equals: \"spam\" },\n },\n },\n { as: \"system\" }\n );\n } catch {\n // A failed count must never break reading the form itself.\n form.submissionCount = 0;\n }\n })\n );\n}\n\n/**\n * Tells the form's `afterRead` hook that this read wants the schema and nothing\n * else, so it can skip work whose only purpose is to be displayed.\n */\nconst SCHEMA_ONLY_READ = \"formBuilder.schemaOnlyRead\";\n\nexport async function fetchParentForm(\n formsSlug: string,\n formId: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown> | null> {\n try {\n // D35/D56: read the parent form through the secure managed service as\n // system — the afterCreate hook runs without an ambient user. Replaces the\n // legacy `getCollectionsHandler()` + `overrideAccess` runtime path.\n //\n // The slug arrives RESOLVED. Passing `formOverrides.slug` read the declared\n // name, which a host that renames the collection has taken out of the\n // registry, so the lookup found nothing on exactly the installs the rename\n // API supports.\n //\n // NOT on the caller's transaction, and it cannot be here. The plugin facade\n // rebuilds its context from `user` and `overrideAccess` alone, and\n // `CollectionService.findEntryById` forwards only those two to the entry\n // service, so an executor has nowhere to go on this path: passing one\n // looked like transaction awareness and was discarded before the query.\n //\n // The cost is written down rather than hidden. A submission created inside\n // a transaction alongside a form created in that same transaction cannot\n // see it and is refused, and on a pool whose only connection the\n // transaction holds, this read waits for it. Both fail safely. The pattern\n // for fixing it exists in collection-hook-service and\n // collection-access-service, which forward a context's executor for exactly\n // this reason; bringing it here means changing the entry service and the\n // plugin facade with it.\n const form = await nextly.services.collections.findEntryById(\n formsSlug,\n formId,\n { as: \"system\", context: { [SCHEMA_ONLY_READ]: true } }\n );\n return form;\n } catch (err) {\n // A form that is not there is an answer, and the caller decides what a\n // submission naming no form means. Anything else is the read itself\n // failing, and it is rethrown: swallowing a pool timeout or a throwing\n // `afterRead` hook turned a server fault into \"this form could not be\n // read\", so the writer was told their submission was invalid, with a status\n // that says not to retry.\n if (!NextlyError.isNotFound(err)) throw err;\n nextly.logger.error?.(\"Form Builder: form not found\", {\n formId,\n error: err instanceof Error ? err.message : String(err),\n });\n return null;\n }\n}\n","/**\n * Forms Collection\n *\n * Stores form definitions created via Code-First or Schema Builder.\n * Each form contains field definitions, settings, notifications, and access control.\n *\n * @module collections/forms\n * @since 0.1.0\n */\n\nimport { NextlyError } from \"@nextlyhq/plugin-sdk\";\nimport type { CollectionConfig, FieldConfig, HookContext } from \"nextly\";\nimport {\n text,\n textarea,\n select,\n checkbox,\n date,\n group,\n json,\n relationship,\n} from \"nextly\";\n\nimport type { ResolvedFormBuilderConfig } from \"../types\";\nimport {\n documentReachability,\n parseRedirectReference,\n pickedDocumentField,\n type PickedDocumentField,\n} from \"../utils/redirect-reference\";\nimport {\n applyRedirectPattern,\n type RedirectRelationships,\n type RedirectTargetDocument,\n} from \"../utils/redirect-target\";\n\nimport { accessWithDefaults } from \"./access-defaults\";\n\n// ============================================================\n// Type Augmentation for Custom Admin Components\n// ============================================================\n// These types extend CollectionAdminOptions to support custom admin views.\n// This augmentation will be removed once nextly exports these types.\n\n/**\n * Component path string format.\n * Format: `\"package-name/path#ExportName\"`\n */\ntype ComponentPath = string;\n\n/**\n * Custom view configuration for replacing default admin views.\n */\ninterface CollectionAdminViewConfig {\n /** Component path to the custom view component */\n Component: ComponentPath;\n}\n\n/**\n * Custom components configuration for collection admin UI.\n */\ninterface CollectionAdminComponents {\n views?: {\n Edit?: CollectionAdminViewConfig;\n List?: CollectionAdminViewConfig;\n };\n BeforeListTable?: ComponentPath;\n AfterListTable?: ComponentPath;\n BeforeEdit?: ComponentPath;\n AfterEdit?: ComponentPath;\n}\n\n/**\n * Extended CollectionConfig with custom admin components support.\n * This extends the base CollectionConfig to include the components property.\n */\ninterface ExtendedCollectionConfig extends Omit<CollectionConfig, \"admin\"> {\n admin?: CollectionConfig[\"admin\"] & {\n /** Whether this collection is provided by a plugin */\n isPlugin?: boolean;\n components?: CollectionAdminComponents;\n };\n}\n\n/**\n * Generate the Forms collection configuration.\n *\n * Creates a collection for storing form definitions with fields,\n * settings, notifications, and access control.\n *\n * @param pluginConfig - Resolved plugin configuration\n * @returns ExtendedCollectionConfig for the Forms collection (with custom admin components)\n *\n * @example\n * ```typescript\n * const formsCol = formsCollection(resolvedConfig);\n * // Returns a collection with slug \"forms\" (or custom slug from config)\n * ```\n */\n/**\n * The plugin's hooks, with a host's own appended per phase.\n *\n * A host may pass `formOverrides.hooks`, and the trailing `...overrides` spread\n * would otherwise replace this object outright — taking with it the slug\n * generation, the at-least-one-field rule and the redirect check, none of\n * which are the host's to remove. Those are invariants of the collection this\n * plugin ships; an override extends it rather than disarming it.\n *\n * Handlers in `pluginHooks` run FIRST — they are transformations a host may\n * want to build on, like the generated slug. Handlers in `trailing` run LAST,\n * after every host handler in that phase, which is where an INVARIANT has to\n * sit: one placed first is checked against a payload the host can still\n * rewrite afterwards.\n */\nfunction withHostHooks<T>(\n pluginHooks: T,\n hostHooks: T | undefined,\n trailing: Partial<Record<string, unknown[]>> = {}\n): T {\n // Annotated rather than asserted. `hostHooks ?? {}` widens to `T | {}`\n // under the generic and cannot be indexed by phase name; a cast says the\n // same thing but reads as removable, and `--fix` removes it.\n const host: Record<string, unknown> = { ...hostHooks };\n\n const asList = (value: unknown) =>\n Array.isArray(value) ? value : value === undefined ? [] : [value];\n\n const merged: Record<string, unknown> = { ...host };\n for (const [phase, handlers] of Object.entries(\n pluginHooks as Record<string, unknown>\n )) {\n merged[phase] = [...asList(handlers), ...asList(host[phase])];\n }\n for (const [phase, handlers] of Object.entries(trailing)) {\n merged[phase] = [...asList(merged[phase]), ...asList(handlers)];\n }\n return merged as T;\n}\n\n/**\n * Refuses a write whose redirect target the resolver could not use.\n *\n * One function, called from two phases. `beforeValidate` so an author gets the\n * error where the rest of the form's errors appear, and LAST in `beforeChange`\n * so the guarantee survives a host hook: a check placed before host handlers\n * judges a payload they can still rewrite, and one that can be rewritten past\n * is not an invariant.\n *\n * `setsSettings` mirrors the fields rule above. An update carries the patch\n * rather than the merged document, so treating an absent `settings` as empty\n * would reject every partial update — renaming a form — for a setting it never\n * touched.\n */\n/**\n * The settings a write is SETTING to a page redirect, or null for every other\n * write.\n *\n * `setsSettings` mirrors the fields rule above. An update carries the patch\n * rather than the merged document, so treating an absent `settings` as empty\n * would refuse a rename for a setting it never touched.\n */\nfunction documentRedirectSettings(\n settings: unknown\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (typeof settings !== \"object\" || settings === null) return null;\n const record = settings as Record<string, unknown>;\n const field = pickedDocumentField(record);\n return field ? { settings: record, field } : null;\n}\n\nfunction pageRedirectSettings(\n data: Record<string, unknown> | undefined,\n operation: string\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (!data) return null;\n if (operation !== \"create\" && data.settings === undefined) return null;\n return documentRedirectSettings(data.settings);\n}\n\n/**\n * Whether the form this write leaves behind can receive a submission.\n *\n * A form's `status` is its own lifecycle — draft, published, closed — and only\n * a published form accepts submissions, so only a published form can send a\n * visitor anywhere at all. That is the whole reason this rule is conditional:\n * a draft form pointing at a draft page is fine, because the two go live\n * together.\n *\n * The stored status is consulted when the write does not carry one. Publishing\n * a form and editing its settings are separate saves, and an update that never\n * mentions `status` leaves the stored one standing — so reading an absent\n * field as \"not published\" would wave through exactly the case this rule\n * exists to catch.\n */\nexport function formAcceptsSubmissions(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined\n): boolean {\n return (data?.status ?? originalData?.status) === \"published\";\n}\n\n/**\n * The stored `settings`, which is not the shape the payload uses.\n *\n * Measured on 2026-08-25: the incoming write carries `settings` as an OBJECT,\n * and the same field on `originalData` comes back as JSON TEXT. Reading the\n * stored row with the payload's reader answers \"no redirect configured\" for\n * every form that has one — silently, and indistinguishably from a form that\n * genuinely has none.\n */\nfunction storedSettings(\n value: unknown\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (typeof value === \"string\") {\n try {\n return documentRedirectSettings(JSON.parse(value));\n } catch {\n return null;\n }\n }\n return documentRedirectSettings(value);\n}\n\n/**\n * The redirect target already stored on the form, for a write that does not\n * mention it.\n *\n * Publishing a form is such a write: it carries `status` and nothing else, and\n * the target it inherits is the one a visitor will be sent to. Without this,\n * the rule would guard the save that PICKS a draft page and ignore the save\n * that makes that same pairing reachable — protecting one path while appearing\n * to protect both.\n *\n * Nothing here is written back. `originalData` is the stored row, not the\n * payload; normalising it would edit a document this write never touched.\n */\nfunction storedRedirectReference(\n originalData: Record<string, unknown> | undefined,\n patterns: RedirectRelationships\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n /** Whether that collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const picked = storedSettings(originalData?.settings);\n if (!picked) return null;\n\n const collections = Object.keys(patterns);\n const reference = parseRedirectReference(\n picked.settings[picked.field],\n collections\n );\n if (!reference) return null;\n\n // A collection dropped from `redirectRelationships` since the form was saved\n // is REPORTED, not discarded. Discarding it made the rule skip a form whose\n // destination can no longer be built at all, so publishing succeeded and\n // every submission afterwards resolved to no redirect — the silent version\n // of the failure the rule exists to prevent.\n return {\n reference,\n field: picked.field,\n configured: collections.includes(reference.collection),\n };\n}\n\n/**\n * Writes the parser's normalised names back into the row being saved.\n *\n * Not merely read: trimming only the parsed copy leaves the padded name in\n * `data`, so this rule accepts the write and the framework's own relationship\n * validator downstream still sees `\" pages \"`. The plugin and the framework\n * then disagree about a value this parser has already decided.\n */\nfunction normalizeStoredReference(\n settings: Record<string, unknown>,\n field: PickedDocumentField,\n reference: { collection: string; id: string }\n) {\n const stored = settings[field];\n\n if (typeof stored === \"string\") {\n if (stored !== reference.id) settings[field] = reference.id;\n return;\n }\n if (typeof stored !== \"object\" || stored === null || Array.isArray(stored)) {\n return;\n }\n\n const record = stored as Record<string, unknown>;\n if (record.relationTo !== reference.collection) {\n settings[field] = { ...record, relationTo: reference.collection };\n }\n}\n\nfunction namedRedirectReference(\n data: Record<string, unknown> | undefined,\n operation: string,\n patterns: RedirectRelationships\n): {\n settings: Record<string, unknown>;\n field: PickedDocumentField;\n /** Null when the settings name a redirect this cannot read. */\n reference: { collection: string; id: string } | null;\n /** Whether that collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const picked = pageRedirectSettings(data, operation);\n if (!picked) return null;\n const { settings, field } = picked;\n\n const collections = Object.keys(patterns);\n const reference = parseRedirectReference(settings[field], collections);\n if (reference) normalizeStoredReference(settings, field, reference);\n\n return {\n settings,\n field,\n reference,\n configured:\n reference !== null && collections.includes(reference.collection),\n };\n}\n\n/**\n * The same rule, plus the one question that needs a read: can this document\n * actually fill its collection's pattern?\n *\n * Split from the shape check so the cheap half stays synchronous — the early\n * `beforeValidate` call wants an error, not a database round trip, and making\n * that hook async changes what a caller has to do to observe its rejection.\n *\n * Best effort by design: `req.nextly` is optional, and a slug can be emptied\n * AFTER this form is saved. The submit path keeps its own check and its log\n * line. What this buys is telling the author NOW, while the page they picked\n * is in front of them, rather than leaving a form that saves cleanly and\n * redirects nobody.\n */\n/**\n * Whether this write leaves a form that would send visitors to a page they\n * cannot reach.\n *\n * One question, so it has one implementation and one name. It is the whole\n * rule: a form that accepts submissions is the only kind that redirects\n * anyone, and an unreachable target is the only thing that 404s them. A draft\n * form pointing at a draft page satisfies neither half and is allowed — the\n * picker offers unpublished pages on purpose, because a form is usually\n * configured beside the page it points at and the two go live together.\n */\nexport function wouldStrandVisitors(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined,\n target: RedirectTargetDocument,\n targetCollectionIsLocalized: boolean | undefined\n): boolean {\n return (\n formAcceptsSubmissions(data, originalData) &&\n documentReachability(target, targetCollectionIsLocalized) === \"unreachable\"\n );\n}\n\n/**\n * Whether this write is the one that makes the form able to receive\n * submissions.\n *\n * Distinct from {@link formAcceptsSubmissions}, which answers what the form\n * WILL be: this asks what the write CHANGES, which is what decides whether an\n * inherited target is any of its business. A form that was already published\n * is not made reachable again by an edit that merely restates its status.\n */\nfunction publishesForm(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined\n): boolean {\n if (data?.status !== \"published\") return false;\n // Already published before this write, so this write is not what makes the\n // form reachable. The admin sends `status` on every save exactly as it sends\n // `settings`, so reading the value alone treats every edit to a live form as\n // the moment it went live.\n return originalData?.status !== \"published\";\n}\n\n/**\n * The page this write leaves the form pointing at, and whether the write\n * CHOSE it.\n *\n * The distinction decides which refusals apply. A write that names a page\n * answers for that page; a write that only publishes the form, or renames it,\n * inherits whatever is stored and must not be refused for it.\n */\n/**\n * Whether two readings name the same document in the same field.\n *\n * `null` on either side is not a match: a reference this cannot read has not\n * been shown to be the stored one.\n */\nfunction sameTarget(\n named: {\n field: PickedDocumentField;\n reference: { collection: string; id: string } | null;\n },\n stored: {\n field: PickedDocumentField;\n reference: { collection: string; id: string };\n } | null\n): boolean {\n if (!named.reference || !stored) return false;\n return (\n stored.field === named.field &&\n stored.reference.collection === named.reference.collection &&\n stored.reference.id === named.reference.id\n );\n}\n\n/**\n * What a write that NAMES a target is answering for.\n *\n * NAMING is not CHOOSING, and the refusal has to come after that distinction\n * rather than before it. The admin's save sends the whole `settings` object on\n * every write, so a rename carries the redirect it never touched — judging the\n * mention meant a form whose collection had since been unconfigured could not\n * be edited at all.\n */\nfunction judgeNamedReference(\n named: {\n field: PickedDocumentField;\n reference: { collection: string; id: string } | null;\n configured: boolean;\n },\n stored: {\n field: PickedDocumentField;\n reference: { collection: string; id: string };\n } | null\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n chosen: boolean;\n configured: boolean;\n} {\n if (sameTarget(named, stored) && named.reference) {\n return {\n reference: named.reference,\n field: named.field,\n chosen: false,\n configured: named.configured,\n };\n }\n\n // This write moved the target, so it answers for what it moved it to.\n if (!named.reference || !named.configured) {\n throw redirectRefusal(\n \"Choose a page to redirect to, or pick a different confirmation.\",\n named.field\n );\n }\n return {\n reference: named.reference,\n field: named.field,\n chosen: true,\n configured: true,\n };\n}\n\nfunction redirectReferenceForWrite(\n context: HookContext,\n patterns: RedirectRelationships\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n chosen: boolean;\n /** Whether the target's collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const stored = storedRedirectReference(\n context.originalData as Record<string, unknown> | undefined,\n patterns\n );\n const named = namedRedirectReference(\n context.data,\n context.operation,\n patterns\n );\n\n if (named) return judgeNamedReference(named, stored);\n\n // A write that REPLACES `settings` has answered the question: this form no\n // longer redirects to a page. Inheriting the old target there would refuse\n // the very edit that removes an invalid redirect, which is the one edit an\n // author in that state needs to make. Only a write that omits `settings`\n // inherits.\n const data = context.data as Record<string, unknown> | undefined;\n if (data?.settings !== undefined) return null;\n\n return stored ? { ...stored, chosen: false } : null;\n}\n\n/**\n * Whether the collection's configured pattern can build a URL for this\n * document.\n *\n * A pattern may be a FUNCTION, which is host code and can throw. Authoring\n * must not be blocked by that: the submission path contains the same throw and\n * degrades to no redirect with a log line, so a broken pattern costs a\n * destination rather than the ability to edit forms.\n */\nfunction assertPatternBuildsUrl(\n patterns: RedirectRelationships,\n reference: { collection: string; id: string },\n field: PickedDocumentField,\n target: RedirectTargetDocument\n) {\n let url: string | undefined;\n try {\n url = applyRedirectPattern(patterns[reference.collection], target);\n } catch {\n return;\n }\n if (url) return;\n\n // Pattern-agnostic. The configured pattern may depend on any field, or be a\n // function that declines for its own reasons, so naming a slug states a\n // remedy that often cannot fix the actual failure — and the save is\n // correctly blocked either way, leaving the author following wrong advice.\n throw redirectRefusal(\n `No URL could be built for that page from the pattern configured for ` +\n `\"${reference.collection}\". Choose another page, or ask a developer ` +\n `to check that collection's redirect pattern.`,\n field\n );\n}\n\nexport async function assertRedirectTargetUsable(\n context: HookContext,\n patterns: RedirectRelationships,\n localization: Record<string, boolean>\n) {\n const write = redirectReferenceForWrite(context, patterns);\n if (!write) return;\n const { reference, field, chosen, configured } = write;\n\n // Nothing can be built for a collection that is no longer a redirect target,\n // so there is nothing to read either. Only the write that makes the form\n // reachable answers for it; an unrelated edit inherits the state as it does\n // any other.\n if (!configured) {\n if (\n publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n )\n ) {\n throw redirectRefusal(\n `This form redirects to \"${reference.collection}\", which is no longer ` +\n `configured as a redirect target, so it has nowhere to send anyone. ` +\n `Choose another page, or pick a different confirmation.`,\n field\n );\n }\n return;\n }\n\n const nextly = context.req?.nextly;\n if (!nextly) return;\n\n // Not inside a caller-owned transaction. `context.executor` is present only\n // there, and the contract on it is explicit: a hook that reads the database\n // must use that executor, because a second pooled connection can stall\n // against a small pool while the caller's transaction holds the only one —\n // and a pooled read cannot see the transaction's own uncommitted rows, so a\n // page created in the same transaction would read as missing and this would\n // refuse a correct write.\n //\n // `findByID` takes no executor, and reaching for Drizzle directly here would\n // be raw database access inside a plugin. So the transactional path keeps\n // the shape check and skips the read; the submit path is the backstop it\n // already was.\n if (context.executor) return;\n\n const found = await readRedirectTarget(nextly, reference);\n if (found.status === \"unreadable\") return;\n if (found.status === \"missing\") {\n // A write that neither NAMES the page nor makes the form able to send\n // anyone there does not answer for it: a rename inherits whatever is\n // stored, and refusing it would block an edit for a setting it never\n // touched.\n //\n // Publishing is not that write. It is the moment the pairing starts\n // reaching visitors, and a deleted target is a stricter case than the\n // unpublished one refused below — every submission resolves it to no\n // redirect at all. Returning here on `!chosen` alone let the worse case\n // through while the milder one was refused.\n if (\n !chosen &&\n !publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n )\n ) {\n return;\n }\n throw redirectRefusal(\n `That page no longer exists in \"${reference.collection}\". Pick another.`,\n field\n );\n }\n\n // An inherited target is only judged by the write that PUBLISHES the form.\n // A published form can acquire a draft target without being touched — the\n // page is unpublished later, or the pairing predates this rule — and\n // refusing every subsequent rename would hold the form hostage to a state\n // the write neither created nor mentions. A write that CHOOSES a target\n // answers for it whatever else it does.\n const judged =\n chosen ||\n publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n );\n\n if (\n judged &&\n wouldStrandVisitors(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined,\n found.document,\n localization[reference.collection]\n )\n ) {\n throw redirectRefusal(\n `That page is not published yet, and this form is. A visitor sent ` +\n `there would reach a \"page not found\". Publish the page, or save ` +\n `this form as a draft until you do.`,\n field\n );\n }\n\n // The pattern belongs to the write that CHOOSES a page. A form being\n // published does not get refused for a pattern its author never edited.\n if (chosen)\n assertPatternBuildsUrl(patterns, reference, field, found.document);\n}\n\n/**\n * The target document, as one of three outcomes.\n *\n * Three rather than a nullable document, because \"the page is not there\" and\n * \"I could not ask\" are different answers and only the first is the author's\n * to fix. Collapsing them has been wrong in both directions on this rule:\n * refusing on any failure blocks a save a retry would complete, and skipping\n * on any failure loses the deleted-page refusal entirely.\n */\nasync function readRedirectTarget(\n nextly: NonNullable<NonNullable<HookContext[\"req\"]>[\"nextly\"]>,\n reference: { collection: string; id: string }\n): Promise<\n | { status: \"ok\"; document: RedirectTargetDocument }\n | { status: \"missing\" }\n | { status: \"unreadable\" }\n> {\n try {\n const row = (await nextly.findByID({\n collection: reference.collection,\n id: reference.id,\n })) as RedirectTargetDocument | null;\n return row ? { status: \"ok\", document: row } : { status: \"missing\" };\n } catch (error) {\n return isMissingTarget(error)\n ? { status: \"missing\" }\n : { status: \"unreadable\" };\n }\n}\n\n/**\n * Whether a failed read means the document is GONE, or that it could not be\n * asked for.\n *\n * Both arrive as exceptions — `findByID` throws `NOT_FOUND` for a missing\n * entry rather than returning null, despite a signature that says `| null` —\n * so the two are separated by the error's own code. Not by its message, which\n * is prose, and not by catching everything, which has now been wrong in both\n * directions: refusing on any failure blocks a save a retry would complete,\n * and skipping on any failure loses the deleted-page refusal.\n */\nexport function isMissingTarget(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { code?: unknown }).code === \"NOT_FOUND\"\n );\n}\n\n/**\n * Records the moment a form first goes live.\n *\n * `status` on this collection is an ordinary select, not the framework's\n * publish lifecycle, so nothing else writes down that a form was ever public.\n * Without that, `closed` stands for two different histories — a form an author\n * released and later stopped, and one created closed that nobody ever saw —\n * and the public by-slug endpoint served both.\n *\n * Written once. A form republished after being closed keeps the original date,\n * because the question this answers is \"has this ever been public\", not \"when\n * was it last\".\n */\nfunction stampFirstPublish(context: HookContext): void {\n const data = context.data as Record<string, unknown> | undefined;\n if (!data) return;\n const stored = context.originalData as Record<string, unknown> | undefined;\n\n // Written here or not at all. Duplicating an entry copies every non-system\n // field, so a copy of a closed form arrives carrying the original's stamp\n // and would qualify as previously public at a slug nobody has ever seen. The\n // same goes for any caller that simply sends the field. Removing it from the\n // patch leaves a stamp that IS stored untouched, because a column absent\n // from an update is a column not written.\n delete data.wentLiveAt;\n\n if (recordedStamp(stored) !== undefined) return;\n\n // Either side of the transition is proof. A form live before this field\n // existed carries no stamp, so its first edit is the only chance to record\n // one — and if that edit is the one CLOSING it, reading the incoming status\n // alone would miss it and take a form that had been public offline.\n if (data.status === \"published\" || stored?.status === \"published\") {\n data.wentLiveAt = new Date();\n }\n}\n\n/** The stamp a form already carries, treating blank as absent. */\nfunction recordedStamp(stored: Record<string, unknown> | undefined): unknown {\n const value = stored?.wentLiveAt;\n return value === null || value === \"\" ? undefined : value;\n}\n\n/** One shape for every refusal this rule makes, so callers see one field. */\nfunction redirectRefusal(message: string, field: PickedDocumentField) {\n // The path names the field the author actually filled in. Reporting every\n // refusal against `redirectPage` points a form configured through the URL\n // option at a control it does not show.\n return NextlyError.validation({\n errors: [{ path: `settings.${field}`, code: \"REQUIRED\", message }],\n });\n}\n\nexport function formsCollection(\n pluginConfig: ResolvedFormBuilderConfig\n): ExtendedCollectionConfig {\n const {\n slug,\n labels,\n fields: additionalFields,\n access: accessOverrides,\n hooks: hookOverrides,\n admin: adminOverrides,\n ...overrides\n } = pluginConfig.formOverrides;\n\n // Derived from the map rather than carried beside it: the collections a form\n // may redirect to are exactly the ones with a URL pattern configured, so\n // there is no second list to fall out of step with the first.\n const redirectCollections = Object.keys(pluginConfig.redirectRelationships);\n\n // Build settings group fields based on plugin configuration\n const settingsFields: FieldConfig[] = [\n text({\n name: \"submitButtonText\",\n label: \"Submit Button Text\",\n defaultValue: \"Submit\",\n admin: {\n description: \"Text displayed on the form submit button\",\n },\n }),\n\n select({\n name: \"confirmationType\",\n label: \"After Submission\",\n defaultValue: \"message\",\n options: [\n { label: \"Show Message\", value: \"message\" },\n { label: \"Redirect to URL\", value: \"redirect\" },\n ...(redirectCollections.length > 0\n ? [{ label: \"Redirect to Page\", value: \"relationship\" }]\n : []),\n ],\n admin: {\n description: \"What happens after a successful form submission\",\n },\n }),\n\n textarea({\n name: \"successMessage\",\n label: \"Success Message\",\n defaultValue: \"Thank you for your submission!\",\n admin: {\n description: \"Message shown after successful submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"message\",\n },\n },\n }),\n\n text({\n name: \"redirectUrl\",\n label: \"Redirect URL\",\n admin: {\n description: \"URL to redirect to after submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"redirect\",\n },\n },\n }),\n ];\n\n // Add redirect relationship field if configured\n if (redirectCollections.length > 0) {\n settingsFields.push(\n relationship({\n name: \"redirectPage\",\n label: \"Redirect Page\",\n relationTo: redirectCollections,\n admin: {\n description: \"Select a page to redirect to after submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"relationship\",\n },\n },\n })\n );\n }\n\n // Add remaining settings fields\n settingsFields.push(\n checkbox({\n name: \"allowMultipleSubmissions\",\n label: \"Allow Multiple Submissions\",\n defaultValue: true,\n admin: {\n description:\n \"Allow the same user/IP to submit this form multiple times\",\n },\n }),\n\n checkbox({\n name: \"honeypotEnabled\",\n label: \"Honeypot\",\n admin: {\n description:\n \"Per-form honeypot override; unset inherits the plugin default\",\n },\n }),\n\n checkbox({\n name: \"captchaEnabled\",\n label: \"Enable reCAPTCHA\",\n admin: {\n description:\n \"Per-form reCAPTCHA override; unset inherits the plugin default\",\n },\n }),\n\n text({\n name: \"captchaSiteKey\",\n label: \"reCAPTCHA Site Key\",\n admin: {\n description: \"Override the default reCAPTCHA site key for this form\",\n condition: {\n field: \"settings.captchaEnabled\",\n equals: true,\n },\n },\n })\n );\n\n // Build the default fields array\n const defaultFields: FieldConfig[] = [\n // ============================================================\n // Basic Information\n // ============================================================\n text({\n name: \"name\",\n label: \"Form Name\",\n required: true,\n admin: {\n description: \"Internal name for this form (not shown to users)\",\n },\n }),\n\n text({\n name: \"slug\",\n label: \"Slug\",\n required: true,\n unique: true,\n admin: {\n description: \"URL-friendly identifier. Used in API: /api/forms/{slug}\",\n },\n }),\n\n textarea({\n name: \"description\",\n label: \"Description\",\n admin: {\n description: \"Internal notes about this form's purpose\",\n },\n }),\n\n // ============================================================\n // Form Fields (JSON schema managed by Form Builder UI)\n // ============================================================\n json({\n name: \"fields\",\n label: \"Form Fields\",\n required: true,\n defaultValue: [],\n admin: {\n description:\n \"Form field configuration. Managed by the Form Builder UI.\",\n },\n }),\n\n // ============================================================\n // Form Settings\n // ============================================================\n group({\n name: \"settings\",\n label: \"Form Settings\",\n fields: settingsFields,\n }),\n\n // ============================================================\n // Email Notifications\n // ============================================================\n json({\n name: \"notifications\",\n label: \"Notifications\",\n defaultValue: [],\n admin: {\n description:\n \"Email notification rules. Managed by the Form Builder UI.\",\n },\n }),\n\n // ============================================================\n // Status\n // ============================================================\n select({\n name: \"status\",\n label: \"Status\",\n required: true,\n defaultValue: \"draft\",\n options: [\n { label: \"Draft\", value: \"draft\" },\n { label: \"Published\", value: \"published\" },\n { label: \"Closed\", value: \"closed\" },\n ],\n admin: {\n description:\n \"Only published forms can receive submissions. Closed forms display a message instead.\",\n },\n }),\n\n // NOT `firstPublishedAt`, which is the obvious name and the one to reach\n // for next. The framework owns that name on collections it manages the\n // publish lifecycle for, and a write naming it from a plugin collection is\n // stripped on the way to the insert — silently, leaving the column null and\n // every closed form reading as one that was never public.\n date({\n name: \"wentLiveAt\",\n label: \"First Published\",\n admin: {\n readOnly: true,\n description:\n \"When this form first went live. Written once and never changed.\",\n },\n }),\n\n textarea({\n name: \"closedMessage\",\n label: \"Closed Form Message\",\n defaultValue: \"This form is no longer accepting submissions.\",\n admin: {\n description: \"Message shown when form status is 'closed'\",\n condition: {\n field: \"status\",\n equals: \"closed\",\n },\n },\n }),\n ];\n\n // Process additional fields from formOverrides\n // Note: Function-style overrides should be resolved in plugin.ts before reaching here\n let finalFields: FieldConfig[];\n\n if (Array.isArray(additionalFields) && additionalFields.length > 0) {\n // Merge: append additional fields to defaults\n finalFields = [...defaultFields, ...additionalFields];\n } else {\n finalFields = defaultFields;\n }\n\n // Convert hyphenated slug to underscored table name for database compatibility\n const dbName = slug.replace(/-/g, \"_\");\n\n return {\n slug,\n dbName,\n labels,\n fields: finalFields,\n timestamps: true,\n\n admin: {\n isPlugin: true,\n group: \"Forms\",\n order: 50,\n useAsTitle: \"name\",\n description:\n \"Create and manage your form templates using the visual form builder. Create and manage forms for collecting user submissions\",\n\n // Custom components for visual form builder\n components: {\n views: {\n // Replace default edit view with visual Form Builder\n Edit: {\n Component: \"@nextlyhq/plugin-form-builder/admin#FormBuilderView\",\n },\n },\n },\n\n ...adminOverrides,\n },\n\n // Access control with sensible defaults\n // `read: true` — a form is public because a site renders it. The rest are\n // the shared defaults.\n access: accessWithDefaults(accessOverrides, { read: true }),\n\n hooks: withHostHooks(\n {\n // Auto-generate slug from name if not provided\n beforeValidate: [\n (context: HookContext) => {\n const { data, operation } = context;\n\n // Auto-generate slug from name if not provided (only on create)\n if (operation === \"create\" && data && !data.slug && data.name) {\n data.slug = String(data.name)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n }\n\n // A form must have at least one field, checked against what the\n // write actually sets. An update carries the patch rather than the\n // merged document, so treating an absent `fields` as empty rejects\n // every partial update -- renaming a form, or changing a setting --\n // even though its fields are untouched and still there.\n const setsFields =\n operation === \"create\" || data?.fields !== undefined;\n if (\n data &&\n setsFields &&\n (data.fields === undefined ||\n (Array.isArray(data.fields) && data.fields.length === 0))\n ) {\n // Typed, so the rejection survives as a validation failure with\n // its field issue rather than being read as a crash and replaced\n // with a generic server-fault message.\n throw NextlyError.validation({\n errors: [\n {\n path: \"fields\",\n code: \"REQUIRED\",\n message: \"Form must have at least one field.\",\n },\n ],\n });\n }\n\n // Reported here so an author sees it beside the form's other\n // errors. The GUARANTEE is the trailing `beforeChange` call below,\n // which a host hook cannot run after.\n // Shape only at this phase: the same reading, without the\n // stored document a refusal for an unconfigured collection would\n // need. `assertRedirectTargetUsable` below makes that call.\n namedRedirectReference(\n data,\n operation,\n pluginConfig.redirectRelationships\n );\n\n return data;\n },\n ],\n },\n hookOverrides,\n {\n // The last COLLECTION-level phase, after every host handler in it. A\n // host `beforeValidate` may rewrite the payload once the earlier call\n // has passed, so the check has to run again here.\n //\n // It is not the last mutating point in the write, and saying so would\n // be false: `collection-mutation-service` runs FIELD-level\n // `beforeChange` hooks after this one, and a host may contribute a\n // field through `formOverrides.fields`. Nothing collection-level can\n // sit after that — the next steps are hashing and the insert. What\n // bounds the consequence is the submit path, which resolves an\n // unusable target to NO redirect and logs it, never to a wrong one.\n beforeChange: [\n async (context: HookContext) => {\n await assertRedirectTargetUsable(\n context,\n pluginConfig.redirectRelationships,\n pluginConfig.redirectTargetLocalization\n );\n stampFirstPublish(context);\n return context.data;\n },\n ],\n }\n ),\n\n // Spread any additional overrides (excluding already used properties)\n ...overrides,\n };\n}\n","/**\n * Where a form sends the visitor after a successful submission, when the\n * author picked a PAGE rather than typing a URL.\n *\n * The plugin cannot derive this. A site's URL structure belongs to the site:\n * the playground serves the `pages` collection at `/{slug}` from its own\n * catch-all route, and another app may serve the same collection under\n * `/blog/{slug}` or behind a locale segment. Nothing inside a CMS package can\n * read that decision off the collection, so the mapping is configuration.\n *\n * A pattern covers the common case and a function covers the rest — a path\n * that depends on a parent document, a locale, or anything else the site\n * knows and this package does not.\n *\n * The pattern syntax is not invented here. `previewUrlFromTemplate` already\n * turns a `{field}` path into a URL for preview links, for the same reason and\n * with the same constraint, so this uses it rather than substituting\n * alongside it: two rules that agree today would drift, and a wrong URL still\n * looks like a URL. Encoding and the treatment of `0` and `false` come with\n * it.\n */\nimport { previewUrlFromTemplate } from \"nextly/config\";\n\nimport type { RedirectTargetDocument } from \"./redirect-reference\";\n\nexport type { RedirectTargetDocument } from \"./redirect-reference\";\nexport {\n documentReachability,\n type Reachability,\n hasPublishLifecycle,\n parseRedirectReference,\n pickedDocumentField,\n type PickedDocumentField,\n type RedirectReference,\n} from \"./redirect-reference\";\n\n/**\n * How documents in one collection become URLs.\n *\n * A string is a path with `{field}` placeholders filled from the document.\n * A function receives the document and returns the path itself.\n */\nexport type RedirectUrlPattern =\n | string\n | ((document: RedirectTargetDocument) => string | undefined);\n\n/** The collection-to-pattern map, after the array shorthand is normalised. */\nexport type RedirectRelationships = Record<string, RedirectUrlPattern>;\n\n/**\n * The pattern assumed for the `string[]` shorthand.\n *\n * `redirectRelationships: [\"pages\"]` means \"pages are redirect targets\" and\n * says nothing about their URLs, so it needs a default. This one matches the\n * routing the page builder ships and the scaffold templates generate.\n */\nexport const DEFAULT_REDIRECT_PATTERN = \"/{slug}\";\n\n/**\n * Both accepted spellings of the option reduced to the map.\n *\n * Normalised at the edge so everything downstream reads ONE shape — including\n * the list of collections, which is `Object.keys` of this rather than a second\n * field that agrees with it today.\n */\nexport function normalizeRedirectRelationships(\n option: string[] | RedirectRelationships | undefined\n): RedirectRelationships {\n if (!option) return {};\n const entries = Array.isArray(option)\n ? option.map(collection => [collection, DEFAULT_REDIRECT_PATTERN] as const)\n : Object.entries(option);\n\n // An empty pattern is dropped rather than kept, so that being OFFERED as a\n // redirect target and being able to PRODUCE a URL are one decision. Kept,\n // the collection appears in the picker, validation accepts a reference into\n // it, and every submission then resolves to nothing — a form that passes\n // every check and sends nobody anywhere.\n return Object.fromEntries(\n entries.filter(\n ([, pattern]) => typeof pattern !== \"string\" || pattern.trim()\n )\n );\n}\n\n/**\n * The pattern applied to a document, or undefined when it cannot be.\n *\n * Undefined rather than a partial string: a placeholder whose field is empty\n * would otherwise produce `/undefined` or `/`, and a form that sends every\n * visitor to a broken URL is worse than one that sends them nowhere. The\n * caller reports the difference.\n */\nexport function applyRedirectPattern(\n pattern: RedirectUrlPattern,\n document: RedirectTargetDocument\n): string | undefined {\n if (typeof pattern === \"function\") {\n const produced = pattern(document);\n return produced ? produced : undefined;\n }\n return previewUrlFromTemplate(pattern)(document) ?? undefined;\n}\n","/**\n * The access rules both collections this plugin contributes fall back to.\n *\n * Written out per collection they were byte-identical apart from `read`, and\n * two copies of an access policy is the copy that matters most: they agree\n * today, one gets tightened later, and the other silently keeps the old rule\n * while looking deliberate.\n *\n * `read` and `create` are the axes they genuinely differ on, so both are\n * parameters: a form is publicly READABLE because a site renders it, and a\n * submission is publicly CREATABLE because a visitor makes it. Update and\n * delete are the same policy in both collections.\n *\n * @module collections/access-defaults\n */\n\nimport type { CollectionAccessControl } from \"nextly\";\n\n/** A single rule, in either form the collection config accepts. */\ntype AccessRule = NonNullable<CollectionAccessControl[\"read\"]>;\n\n/** The subset of a collection's access declaration these defaults supply. */\nexport type AccessOverrides = Pick<\n CollectionAccessControl,\n \"read\" | \"create\" | \"update\" | \"delete\"\n>;\n\n/** Signed in. */\nconst authenticated: AccessRule = ({ user }) => !!user;\n\n/** Signed in AND holding an administrative role. */\nconst administrative: AccessRule = ({ roles }) =>\n roles.includes(\"admin\") || roles.includes(\"super-admin\");\n\n/**\n * Host overrides applied over this plugin's defaults, per operation.\n *\n * Each override is taken with `??`, so a host may pass `false` to close an\n * operation it would otherwise be given — which `||` would have silently\n * replaced with the default.\n */\nexport function accessWithDefaults(\n overrides: AccessOverrides | undefined,\n defaults: { read?: AccessRule; create?: AccessRule } = {}\n): AccessOverrides {\n return {\n read: overrides?.read ?? defaults.read ?? authenticated,\n create: overrides?.create ?? defaults.create ?? authenticated,\n update: overrides?.update ?? authenticated,\n // Deleting is administrative in both collections; the DB permission layer\n // remains the backstop underneath it.\n delete: overrides?.delete ?? administrative,\n };\n}\n","/**\n * Form Submissions Collection\n *\n * Stores form submission data from user-submitted forms.\n * Each submission contains the submitted data, metadata, and status.\n *\n * @module collections/submissions\n * @since 0.1.0\n */\n\nimport type { CollectionConfig, FieldConfig, HookContext } from \"nextly\";\nimport { text, textarea, select, json, date, relationship } from \"nextly\";\n\nimport type { ResolvedFormBuilderConfig } from \"../types\";\n\nimport { accessWithDefaults } from \"./access-defaults\";\n\n/**\n * Extended CollectionConfig with isPlugin support.\n * This extends the base CollectionConfig to include the isPlugin property.\n */\ninterface ExtendedCollectionConfig extends Omit<CollectionConfig, \"admin\"> {\n admin?: CollectionConfig[\"admin\"] & {\n /** Whether this collection is provided by a plugin */\n isPlugin?: boolean;\n };\n}\n\n/**\n * Generate the Form Submissions collection configuration.\n *\n * Creates a collection for storing form submissions with:\n * - Reference to the parent form\n * - Submitted data (JSON)\n * - Status tracking (new, read, archived)\n * - Metadata (IP, user agent, timestamp)\n * - Internal notes for admin use\n *\n * @param pluginConfig - Resolved plugin configuration\n * @returns CollectionConfig for the Submissions collection\n *\n * @example\n * ```typescript\n * const submissionsCol = submissionsCollection(resolvedConfig);\n * // Returns a collection with slug \"form-submissions\" (or custom slug from config)\n * ```\n */\nexport function submissionsCollection(\n pluginConfig: ResolvedFormBuilderConfig\n): ExtendedCollectionConfig {\n const {\n slug,\n labels,\n fields: additionalFields,\n access: accessOverrides,\n ...overrides\n } = pluginConfig.formSubmissionOverrides;\n\n // Get the forms collection slug for the relationship\n const formSlug = pluginConfig.formOverrides.slug;\n\n // Build the default fields array\n const defaultFields: FieldConfig[] = [\n // ============================================================\n // Form Relationship\n // ============================================================\n relationship({\n name: \"form\",\n label: \"Form\",\n relationTo: formSlug,\n required: true,\n admin: {\n readOnly: true,\n description: \"The form this submission belongs to\",\n },\n }),\n\n // ============================================================\n // Submission Data (JSON)\n // ============================================================\n json({\n name: \"data\",\n label: \"Submission Data\",\n required: true,\n admin: {\n readOnly: true,\n description: \"The submitted form data as JSON\",\n },\n }),\n\n // ============================================================\n // Status\n // ============================================================\n select({\n name: \"status\",\n label: \"Status\",\n required: true,\n defaultValue: \"new\",\n options: [\n { label: \"New\", value: \"new\" },\n { label: \"Read\", value: \"read\" },\n { label: \"Archived\", value: \"archived\" },\n // Spam is stored and flagged, never silently dropped: a false\n // positive stays reviewable and recoverable (\"Not spam\" = back to new).\n { label: \"Spam\", value: \"spam\" },\n ],\n admin: {\n description: \"Track whether this submission has been reviewed\",\n },\n }),\n\n text({\n name: \"spamReason\",\n label: \"Spam Reason\",\n admin: {\n readOnly: true,\n description:\n \"Which spam check flagged this submission (empty for clean submissions)\",\n },\n }),\n\n // ============================================================\n // Internal Notes\n // ============================================================\n textarea({\n name: \"notes\",\n label: \"Internal Notes\",\n admin: {\n description: \"Notes visible only to admins (not shown to submitter)\",\n },\n }),\n\n // ============================================================\n // Metadata\n // ============================================================\n text({\n name: \"ipAddress\",\n label: \"IP Address\",\n admin: {\n readOnly: true,\n description: \"IP address of the submitter\",\n },\n }),\n\n text({\n name: \"userAgent\",\n label: \"User Agent\",\n admin: {\n readOnly: true,\n description: \"Browser/device information of the submitter\",\n },\n }),\n\n date({\n name: \"submittedAt\",\n label: \"Submitted At\",\n required: true,\n admin: {\n readOnly: true,\n description: \"When the form was submitted\",\n },\n }),\n\n // Honesty stamps for admin edits of submitted data: a corrected\n // submission must never be indistinguishable from what the visitor sent.\n date({\n name: \"editedAt\",\n label: \"Edited At\",\n admin: {\n readOnly: true,\n description: \"When an admin last edited the submitted data\",\n },\n }),\n\n text({\n name: \"editedBy\",\n label: \"Edited By\",\n admin: {\n readOnly: true,\n description: \"ID of the admin who last edited the submitted data\",\n },\n }),\n ];\n\n // Process additional fields from formSubmissionOverrides\n // Note: Function-style overrides should be resolved in plugin.ts before reaching here\n let finalFields: FieldConfig[];\n\n if (Array.isArray(additionalFields) && additionalFields.length > 0) {\n // Merge: append additional fields to defaults\n finalFields = [...defaultFields, ...additionalFields];\n } else {\n finalFields = defaultFields;\n }\n\n // Convert hyphenated slug to underscored table name (e.g., \"form-submissions\" -> \"form_submissions\")\n const dbName = slug.replace(/-/g, \"_\");\n\n return {\n slug,\n dbName,\n labels,\n fields: finalFields,\n timestamps: true,\n\n // Submissions carry visitor-entered answers plus ipAddress/userAgent, so the\n // default `entry.*` events stay suppressed — otherwise every submission would\n // be delivered to any endpoint subscribed to `entry.created` or `*`. Instead\n // emit a curated `form.submission.created` carrying ONLY safe metadata (which\n // form, when, and the status) — never the answers, IP, or user agent.\n webhooks: {\n record: false,\n emit: {\n event: \"form.submission.created\",\n fields: [\"form\", \"submittedAt\", \"status\"],\n },\n },\n\n admin: {\n isPlugin: true,\n group: \"Forms\",\n order: 51,\n useAsTitle: \"id\",\n description: \"View and manage form submissions\",\n // Submissions are created by visitors submitting forms — a hand-typed\n // \"New Submission\" would be fiction, so the admin never offers one.\n disableCreate: true,\n\n components: {\n // The full submissions experience (per-form columns, status tabs,\n // drawer detail, export) replaces the generic list.\n views: {\n List: {\n Component: \"@nextlyhq/plugin-form-builder/admin#SubmissionsView\",\n },\n },\n },\n\n ...overrides.admin,\n },\n\n // Access control with sensible defaults\n // Public can create (submit forms), but only authenticated users can read/update/delete\n // `create: true` — a visitor submits without an account. Reading, updating\n // and deleting are the shared defaults.\n access: accessWithDefaults(accessOverrides, { create: true }),\n\n hooks: {\n // Auto-set submittedAt timestamp on creation. (The edit stamp for\n // updates is registered in plugin.ts init — direct registry hooks are\n // the path that runs for every API surface.)\n beforeValidate: [\n (context: HookContext) => {\n const { data, operation } = context;\n if (operation === \"create\" && data && !data.submittedAt) {\n data.submittedAt = new Date();\n }\n return data;\n },\n ],\n\n afterChange: [\n (context: HookContext) => {\n const { data } = context;\n return data;\n },\n ],\n },\n\n // Spread any additional overrides (excluding already used properties)\n ...overrides,\n };\n}\n","/**\n * Reading a stored row as one of this plugin's documents.\n *\n * The services layer answers with a loose row -- `id`, `createdAt`, `updatedAt`\n * and an `unknown` index signature -- while this plugin declares concrete\n * documents. There is no overlap for TypeScript to check between the two, so\n * the conversion goes through `unknown` and **nothing here is verified at\n * compile time**. A single assertion does not compile:\n *\n * ```\n * error TS2352: Conversion of type 'CollectionEntry' to type\n * 'SubmissionDocument' may be a mistake because neither type sufficiently\n * overlaps with the other.\n * ```\n *\n * The parameters are therefore `unknown`, which is what the rows honestly are\n * at this boundary. One assertion from `unknown` is legal where two from a\n * partially-known shape were needed, so the widening removes a cast rather\n * than adding one -- but it removes the notation, not the risk.\n *\n * The point of this module is therefore not safety it cannot provide. It is\n * that the unchecked step happens in ONE place a reviewer can find, rather than\n * at six call sites where it reads as ordinary code.\n *\n * @module document-shapes\n */\n\nimport type { FormDocument, SubmissionDocument } from \"./types\";\n\n/**\n * A row as the services layer hands it over.\n *\n * `unknown` rather than `Record<string, unknown>`: the read paths type their\n * results loosely enough that a narrower parameter only moves the assertion to\n * the call site, which is the thing this module exists to stop.\n */\ntype StoredRow = unknown;\n\n/**\n * Read a stored row as a submission.\n *\n * Does not validate, and deliberately cannot throw: every caller is on a path\n * where the row has already committed, so rejecting here would turn a saved\n * submission into an error the visitor is invited to retry.\n */\nexport function asSubmissionDocument(row: StoredRow): SubmissionDocument {\n return row as SubmissionDocument;\n}\n\n/** Read a list of stored rows as submissions. */\nexport function asSubmissionDocuments(\n rows: readonly StoredRow[]\n): SubmissionDocument[] {\n return rows as SubmissionDocument[];\n}\n\n/** Read a stored row as a form. Same contract as {@link asSubmissionDocument}. */\nexport function asFormDocument(row: StoredRow): FormDocument {\n return row as FormDocument;\n}\n","/**\n * What a submission has to be before it is stored, wherever it came from.\n *\n * Two things accept a submission. `submitForm` answers `POST\n * /api/forms/:slug/submit`, and `nextly.forms.submit()` is the Direct API a\n * server-side caller reaches through `getNextly()`. The first transformed,\n * validated and sanitized; the second did none of it, so a submission arriving\n * that way was stored with undeclared keys, values that did not match the\n * form's own schema, and markup intact.\n *\n * The rule lives here rather than in either caller because a rule stated in one\n * of two places is a rule the other can disagree with, and the two had already\n * drifted that far apart. `beforeCreate` on the submissions collection now asks\n * this on every write, so a third caller inherits it without knowing it exists.\n *\n * What is NOT here is the spam machinery: the honeypot, the rate limiter and\n * reCAPTCHA are facts about a REQUEST, not about a row. A trusted server\n * importing a thousand submissions would be rate-limited by its own import, and\n * a honeypot field means nothing without a browser to have skipped it. Those\n * stay on the HTTP path.\n */\n\nimport { stripHtmlTags } from \"nextly\";\n\nimport type { AnyFormField } from \"../types\";\nimport {\n generateZodSchema,\n getValidationErrors,\n getValidationIssues,\n type ValidationIssue,\n transformFormData,\n} from \"../utils/generate-schema\";\n\n/**\n * Form field types that accept free-text string input from end users.\n * These fields can contain HTML injection vectors and must be sanitized.\n *\n * Fields NOT in this set (select, radio, checkbox, number, date, time, file)\n * are constrained by Zod enum/type validation and don't need sanitization.\n */\nconst TEXT_FORM_FIELDS = new Set([\n \"text\",\n \"email\",\n \"textarea\",\n \"phone\",\n \"url\",\n \"hidden\",\n]);\n\n/**\n * Sanitize form submission data by stripping HTML tags from free-text fields.\n *\n * Iterates over the form's field definitions and applies `stripHtmlTags()`\n * to values whose field type is in `TEXT_FORM_FIELDS`. Non-string values\n * and constrained fields (select, radio, checkbox, etc.) are left unchanged.\n *\n * Mutates the data object in place for efficiency.\n *\n * @param data - Validated submission data (mutated in place)\n * @param fields - Form field definitions (used for type-aware dispatch)\n */\nexport function sanitizeSubmissionData(\n data: Record<string, unknown>,\n fields: AnyFormField[]\n): void {\n for (const field of fields) {\n // Plugin field types are not in TEXT_FORM_FIELDS, so they skip naturally.\n if (!TEXT_FORM_FIELDS.has(field.type)) continue;\n\n const value = data[field.name];\n if (typeof value !== \"string\") continue;\n\n data[field.name] = stripHtmlTags(value);\n }\n}\n\n/**\n * How the plugin's own submit handler says a row is its own.\n *\n * The write-seam check has to be lenient in exactly one case: a honeypot or\n * reCAPTCHA hit is stored flagged rather than dropped, so a false positive stays\n * reviewable, and requiring it to be valid would throw away the thing being\n * reviewed. Deciding that from the row's own `status` made it caller-controlled:\n * the submissions collection grants public create, nothing restricts `status`,\n * so anyone could post `status: \"spam\"` and switch validation off.\n *\n * A SYMBOL key on the row is the channel, and it is the only one that both\n * reaches the hook and cannot be reached from a request. `JSON.parse` never\n * produces a symbol key, so nothing a caller posts can carry it, and\n * `JSON.stringify` ignores it, so it cannot be stored by accident. It travels\n * with the row it describes, which is what the alternatives could not do.\n *\n * The alternatives were measured, not assumed. `createEntry`'s `params.context`\n * is unreachable: `wrapCollectionsForPlugin` rebuilds the trailing argument as\n * `{ user, overrideAccess }` and drops everything else. `AsyncLocalStorage`\n * reaches the hook but describes the CALL rather than the row, so a submission\n * written by another hook inside the same call took the exception, and\n * identifying the intended row by its content broke as soon as a host hook\n * normalised the payload, since core rebuilds the payload object on the way.\n * A symbol on the row has neither problem: booting a real Nextly and reading\n * the hook's context back, the row's symbol arrives and the payload's does not.\n */\nconst PLUGIN_SUBMISSION = Symbol.for(\"nextly.plugin-form-builder.submission\");\n\n/** What the handler says about the row it is writing. */\nexport interface SubmissionOriginMarks {\n /**\n * The handler decided this row is spam and is keeping it as evidence, so the\n * write seam sanitizes it but does not require it to be valid.\n */\n keepAsEvidence: boolean;\n /**\n * The parent form the handler has already read, so the write seam can check\n * the payload against it without reading it again. Used only when its id is\n * the one the row names.\n */\n form?: Record<string, unknown>;\n}\n\n/** Mark `row` as this plugin's own write, and hand it back. */\nexport function asPluginSubmission(\n row: Record<string, unknown>,\n marks: SubmissionOriginMarks\n): Record<string, unknown> {\n return Object.defineProperty(row, PLUGIN_SUBMISSION, {\n value: marks,\n enumerable: true,\n configurable: true,\n });\n}\n\n/** A row as it looks once this plugin has marked it. */\ninterface MarkedSubmission {\n [PLUGIN_SUBMISSION]?: SubmissionOriginMarks;\n}\n\n/** What this row was marked as, or nothing when it is somebody else's. */\nexport function submissionMarks(\n row: Record<string, unknown>\n): SubmissionOriginMarks | undefined {\n return (row as MarkedSubmission)[PLUGIN_SUBMISSION];\n}\n\n/** A form value, compared the way a form value can be. */\nfunction sameValue(left: unknown, right: unknown): boolean {\n if (Object.is(left, right)) return true;\n if (left instanceof Date && right instanceof Date) {\n return left.getTime() === right.getTime();\n }\n if (Array.isArray(left) && Array.isArray(right)) {\n return (\n left.length === right.length &&\n left.every((value, index) => sameValue(value, right[index]))\n );\n }\n return false;\n}\n\n/** Whether two submitted payloads say the same thing. */\nexport function sameSubmittedPayload(\n granted: Record<string, unknown>,\n incoming: Record<string, unknown>\n): boolean {\n const answers = Object.entries(granted);\n if (answers.length !== Object.keys(incoming).length) return false;\n return answers.every(([field, answer]) => sameValue(answer, incoming[field]));\n}\n\n/** A submission ready to store, or the reasons it is not. */\nexport interface PreparedSubmission {\n /** Transformed and sanitized. Present whether or not validation passed. */\n data: Record<string, unknown>;\n /**\n * Field name to its first message, which is the shape `getValidationErrors`\n * already returns and the HTTP handler already answers with. Absent when the\n * submission is storable.\n */\n validationErrors?: Record<string, string>;\n /**\n * The same failures carrying their machine codes.\n *\n * Beside the map rather than replacing it: the map is this plugin's public\n * result shape, while an HTTP route needs the code to report `REQUIRED` for a\n * blank answer the way the endpoint always has. Both come from one walk of\n * the issues, so they cannot disagree.\n */\n validationIssues?: ValidationIssue[];\n}\n\n/**\n * Bring a submission to the shape the form declares.\n *\n * Transform, then sanitize, then validate, in that order and for reasons.\n * Transforming projects the payload onto the declared fields, so an undeclared\n * key never reaches the schema or the row. Sanitizing before validating is what\n * makes every rule judge the value that will actually be stored: the other\n * order let `<b></b>` satisfy a required field and then reduced it to an empty\n * string, and made a length rule count markup the visitor never sees.\n *\n * `validate: false` is for content the plugin has already decided to keep as\n * evidence. A honeypot hit is stored flagged rather than dropped so a false\n * positive stays recoverable, and requiring it to be valid would throw the\n * evidence away. It is still transformed and sanitized, because a row nobody\n * validated is exactly the one that should not carry markup.\n *\n * Idempotent, which is what lets the write-seam hook run over data the HTTP\n * handler has already prepared: transforming a projected payload projects it\n * again, and stripping tags from text that has none returns it unchanged.\n */\nexport function prepareSubmission({\n data,\n fields,\n validate,\n}: {\n data: Record<string, unknown>;\n fields: AnyFormField[];\n validate: boolean;\n}): PreparedSubmission {\n const transformed = transformFormData(data, fields);\n sanitizeSubmissionData(transformed, fields);\n\n if (!validate) {\n return { data: transformed };\n }\n\n const result = generateZodSchema(fields).safeParse(transformed);\n if (!result.success) {\n // The transformed payload comes back with the errors rather than nothing,\n // so a caller that wants to store it anyway has the same value the valid\n // path would have produced, minus the schema's blessing.\n return {\n data: transformed,\n validationErrors: getValidationErrors(result),\n // The RAW submission, not the transformed payload. Transformation\n // normalises a value before the schema sees it, so a required\n // multi-select sent a scalar `0` arrives here as `[]` and would read as\n // blank: the visitor answered, and would be told they had not.\n validationIssues: getValidationIssues(result, data),\n };\n }\n\n return { data: result.data };\n}\n","/**\n * Dynamic Zod Schema Generator\n *\n * Converts FormField[] configuration to Zod validation schemas.\n * Supports all form field types with proper validation rules.\n *\n * @module utils/generate-schema\n * @since 0.1.0\n */\n\nimport type { FieldValidationRule } from \"nextly/field-catalog\";\nimport { z } from \"zod\";\n\nimport { isKnownFormField } from \"../types\";\nimport type {\n AnyFieldValidation,\n AnyFormField,\n FormField,\n TextFormField,\n EmailFormField,\n NumberFormField,\n PhoneFormField,\n UrlFormField,\n TextareaFormField,\n SelectFormField,\n CheckboxFormField,\n RadioFormField,\n FileFormField,\n DateFormField,\n TimeFormField,\n HiddenFormField,\n} from \"../types\";\n\nimport { ENFORCED_VALIDATION_RULES } from \"./enforced-validation\";\n\n// ============================================================\n// Main Schema Generator\n// ============================================================\n\n/**\n * Generate a Zod validation schema from form field configuration.\n *\n * Creates a dynamic Zod schema based on the form fields array,\n * applying appropriate validation rules for each field type.\n *\n * @param fields - Array of form field configurations\n * @returns Zod object schema for validating submission data\n *\n * @example\n * ```typescript\n * const fields: FormField[] = [\n * { type: 'text', name: 'firstName', label: 'First Name', required: true },\n * { type: 'email', name: 'email', label: 'Email', required: true },\n * { type: 'number', name: 'age', label: 'Age', validation: { min: 18, max: 120 } },\n * ];\n *\n * const schema = generateZodSchema(fields);\n * const result = schema.safeParse({ firstName: 'John', email: 'john@example.com', age: 25 });\n * ```\n */\nexport function generateZodSchema(\n fields: AnyFormField[]\n): z.ZodObject<Record<string, z.ZodTypeAny>> {\n const schemaShape: Record<string, z.ZodTypeAny> = {};\n\n for (const field of fields) {\n // A plugin-contributed field's value shape is owned by the plugin: accept\n // any value, but keep the base required check so a required plugin field\n // cannot be omitted (z.unknown() alone treats a missing key as valid).\n if (!isKnownFormField(field)) {\n schemaShape[field.name] = field.required\n ? z\n .unknown()\n .refine(\n value => value !== undefined && value !== null && value !== \"\",\n { message: `${field.label || field.name} is required` }\n )\n : z.unknown();\n continue;\n }\n\n // Skip hidden fields without required flag\n if (field.type === \"hidden\" && !field.required) {\n // Still add to schema but make optional\n schemaShape[field.name] = z.string().optional();\n continue;\n }\n\n const fieldSchema = generateFieldSchema(field);\n if (fieldSchema) {\n schemaShape[field.name] = fieldSchema;\n }\n }\n\n return z.object(schemaShape);\n}\n\n// ============================================================\n// Field Schema Generators\n// ============================================================\n\n/**\n * Generate a Zod schema for a single field.\n */\nfunction generateFieldSchema(field: FormField): z.ZodTypeAny | null {\n switch (field.type) {\n case \"text\":\n return generateTextSchema(field);\n case \"email\":\n return generateEmailSchema(field);\n case \"number\":\n return generateNumberSchema(field);\n case \"phone\":\n return generatePhoneSchema(field);\n case \"url\":\n return generateUrlSchema(field);\n case \"textarea\":\n return generateTextareaSchema(field);\n case \"select\":\n return generateSelectSchema(field);\n case \"checkbox\":\n return generateCheckboxSchema(field);\n case \"radio\":\n return generateRadioSchema(field);\n case \"file\":\n return generateFileSchema(field);\n case \"date\":\n return generateDateSchema(field);\n case \"time\":\n return generateTimeSchema(field);\n case \"hidden\":\n return generateHiddenSchema(field);\n default:\n // Unreachable for a known FormField; plugin types are handled in\n // generateZodSchema before this switch is reached.\n return z.unknown();\n }\n}\n\n/**\n * Generate schema for text field.\n */\n/**\n * The author's own wording for a failure, or `undefined` when this field's type\n * does not enforce `message`.\n *\n * Asked of the row rather than read off `validation` directly, for the reason\n * the table exists at all: the editor decides whether to OFFER the control from\n * that row, so a generator honouring a stored message regardless would enforce\n * a value the author was never shown a box for — and, worse, the table would\n * describe a rule it did not govern.\n *\n * `message` was the one rule that stayed outside this. It agreed with the table\n * only because every row happens to list it, which is agreement by coincidence:\n * drop `message` from a row and the control disappears while the wording went\n * on being applied.\n */\nfunction customMessage(field: FormField): string | undefined {\n const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];\n if (!enforced.includes(\"message\")) return undefined;\n return field.validation?.errorMessage;\n}\n\n/**\n * A field's stored bounds, with the custom message already resolved by the row.\n *\n * Handed to the appliers so they cannot read an ungated message: they take a\n * validation object, and giving them one whose `errorMessage` has been through\n * {@link customMessage} makes the rule hold for every applier without each of\n * them having to remember it.\n */\nfunction enforcedValidation(field: FormField): AnyFieldValidation {\n const stored = (field.validation ?? {}) as AnyFieldValidation;\n return { ...stored, errorMessage: customMessage(field) };\n}\n\n/**\n * Which rules a type's schema applies, and how each one constrains a string.\n *\n * The WHICH comes from {@link ENFORCED_VALIDATION_RULES}, which the field\n * editor also reads to decide what to offer. One definition answers both\n * questions, so a rule cannot be offered by the editor and ignored here, or\n * honoured here and hidden there — the two used to state the same thing\n * separately and could only agree by being edited together.\n *\n * The HOW stays here, because it is about zod and the table is deliberately\n * free of it: the editor imports the table, and pulling the schema builder in\n * behind it would ship this file and its validation library to every admin\n * client before anyone opened a field.\n */\nconst STRING_RULE_APPLIERS: Partial<\n Record<\n FieldValidationRule,\n (\n schema: z.ZodString,\n validation: AnyFieldValidation,\n fallback: string\n ) => z.ZodString\n >\n> = {\n minLength: (schema, validation) =>\n validation.minLength === undefined\n ? schema\n : schema.min(\n validation.minLength,\n validation.errorMessage ||\n `Minimum ${validation.minLength} characters required`\n ),\n maxLength: (schema, validation) =>\n validation.maxLength === undefined\n ? schema\n : schema.max(\n validation.maxLength,\n validation.errorMessage ||\n `Maximum ${validation.maxLength} characters allowed`\n ),\n pattern: (schema, validation, fallback) =>\n validation.pattern\n ? schema.regex(\n new RegExp(validation.pattern),\n validation.errorMessage || fallback\n )\n : schema,\n};\n\n/** The same, for the bounds a number carries. */\nconst NUMBER_RULE_APPLIERS: Partial<\n Record<\n FieldValidationRule,\n (schema: z.ZodNumber, validation: AnyFieldValidation) => z.ZodNumber\n >\n> = {\n min: (schema, validation) =>\n validation.min === undefined\n ? schema\n : schema.min(\n validation.min,\n validation.errorMessage || `Minimum value is ${validation.min}`\n ),\n max: (schema, validation) =>\n validation.max === undefined\n ? schema\n : schema.max(\n validation.max,\n validation.errorMessage || `Maximum value is ${validation.max}`\n ),\n};\n\n/**\n * Whether an author-supplied pattern actually replaces a type's intrinsic one.\n *\n * Two conditions, and both are load-bearing: a pattern must be stored, AND the\n * type's row must enforce `pattern`. A type that keeps its own shape check —\n * a phone, a URL — stands it down only for an override that will really be\n * applied, because standing down for one that will not leaves nothing behind.\n */\nfunction overridesPattern(field: FormField): boolean {\n const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];\n if (!enforced.includes(\"pattern\")) return false;\n const validation = (field.validation ?? {}) as AnyFieldValidation;\n return Boolean(validation.pattern);\n}\n\n/**\n * Constrain a string schema by the rules this field's TYPE enforces.\n *\n * A rule the type does not list is not applied even when the field carries a\n * value for it, which is the point: a `pattern` stored on a textarea is a bound\n * the editor never offered, and honouring it here would enforce a restriction\n * its author could not see.\n *\n * `patternMessage` is the type's own wording for a failed pattern — \"Invalid\n * URL format\" reads differently from \"Invalid format\" — so the rule set decides\n * WHICH constraints apply while each type keeps its own voice.\n */\nfunction applyStringRules(\n schema: z.ZodString,\n field: FormField,\n patternMessage: string\n): z.ZodString {\n const validation = enforcedValidation(field);\n let constrained = schema;\n for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {\n const apply = STRING_RULE_APPLIERS[rule];\n if (apply) constrained = apply(constrained, validation, patternMessage);\n }\n return constrained;\n}\n\nfunction generateTextSchema(field: TextFormField): z.ZodTypeAny {\n const schema = applyStringRules(z.string(), field, \"Invalid format\");\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for email field.\n */\nfunction generateEmailSchema(field: EmailFormField): z.ZodTypeAny {\n // The format check is the type's own, not a validation rule: an email field\n // is an email field whether or not its author bounded it further.\n const schema = applyStringRules(\n z.string().email(customMessage(field) || \"Invalid email address\"),\n field,\n \"Invalid email format\"\n );\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for number field.\n */\nfunction generateNumberSchema(field: NumberFormField): z.ZodTypeAny {\n // Zod 4 unified message customization under a single `error` callback;\n // an undefined input is the \"missing required\" case, anything else is a\n // type mismatch.\n let schema = z.number({\n error: issue =>\n issue.input === undefined\n ? customMessage(field) || \"This field is required\"\n : \"Must be a number\",\n });\n\n // The same rule set the editor reads decides which bounds apply here.\n const validation = enforcedValidation(field);\n for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {\n const apply = NUMBER_RULE_APPLIERS[rule];\n if (apply) schema = apply(schema, validation);\n }\n\n if (field.required) {\n return schema;\n }\n\n return schema.optional().nullable();\n}\n\n/**\n * Generate schema for phone field.\n */\nfunction generatePhoneSchema(field: PhoneFormField): z.ZodTypeAny {\n // The default pattern is part of what a phone field IS, so it belongs to the\n // base rather than to the rule set: it applies when the author supplied none,\n // and the author's own pattern replaces it through the rule below.\n //\n // Whether the author CAN replace it is the row's decision, not the stored\n // value's. Standing the base down because a pattern happens to be stored,\n // while `applyStringRules` then declines to apply it because the row does not\n // list `pattern`, would leave the field with no check at all — every string\n // accepted, on the one type whose whole purpose is a shape.\n const base = overridesPattern(field)\n ? z.string()\n : z\n .string()\n .regex(\n /^[\\d\\s\\-+()]+$/,\n customMessage(field) || \"Invalid phone number\"\n );\n\n const schema = applyStringRules(base, field, \"Invalid phone number format\");\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for URL field.\n */\nfunction generateUrlSchema(field: UrlFormField): z.ZodTypeAny {\n const schema = applyStringRules(\n z.string().url(customMessage(field) || \"Invalid URL\"),\n field,\n \"Invalid URL format\"\n );\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for textarea field.\n */\nfunction generateTextareaSchema(field: TextareaFormField): z.ZodTypeAny {\n const schema = applyStringRules(z.string(), field, \"Invalid format\");\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for select field.\n */\nfunction generateSelectSchema(field: SelectFormField): z.ZodTypeAny {\n const validValues = field.options.map(opt => opt.value);\n\n if (field.allowMultiple) {\n // Multi-select: array of valid values\n const schema = z.array(z.enum(validValues as [string, ...string[]]));\n\n if (field.required) {\n return schema.min(\n 1,\n customMessage(field) || \"Please select at least one option\"\n );\n }\n\n return schema.optional();\n }\n\n // Single select\n const schema = z.enum(validValues as [string, ...string[]], {\n error: () => customMessage(field) || \"Please select a valid option\",\n });\n\n if (field.required) {\n return schema;\n }\n\n return schema.optional();\n}\n\n/**\n * Generate schema for checkbox field.\n */\nfunction generateCheckboxSchema(field: CheckboxFormField): z.ZodTypeAny {\n const schema = z.boolean();\n\n if (field.required) {\n // For required checkbox, value must be true\n return schema.refine(val => val === true, {\n message: customMessage(field) || \"This field is required\",\n });\n }\n\n return schema.optional();\n}\n\n/**\n * Generate schema for radio field.\n */\nfunction generateRadioSchema(field: RadioFormField): z.ZodTypeAny {\n const validValues = field.options.map(opt => opt.value);\n\n const schema = z.enum(validValues as [string, ...string[]], {\n error: () => customMessage(field) || \"Please select an option\",\n });\n\n if (field.required) {\n return schema;\n }\n\n return schema.optional();\n}\n\n/**\n * Generate schema for file field.\n *\n * Note: File validation is typically handled server-side.\n * This schema validates file references (IDs or URLs).\n */\nfunction generateFileSchema(field: FileFormField): z.ZodTypeAny {\n if (field.multiple) {\n const schema = z.array(z.string());\n\n if (field.required) {\n return schema.min(\n 1,\n customMessage(field) || \"Please upload at least one file\"\n );\n }\n\n return schema.optional();\n }\n\n // Single file\n const schema = z.string();\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for date field.\n */\nfunction generateDateSchema(field: DateFormField): z.ZodTypeAny {\n // Start with base string schema\n let schema: z.ZodTypeAny = z.string();\n\n // Add date format validation\n schema = (schema as z.ZodString).refine(\n val => {\n if (!val) return true; // Allow empty for optional fields\n const date = new Date(val);\n return !isNaN(date.getTime());\n },\n { message: customMessage(field) || \"Invalid date\" }\n );\n\n // Min/max date validation\n if (field.min || field.max) {\n schema = schema.refine(\n (val: unknown) => {\n if (!val) return true;\n const date = new Date(val as string);\n if (field.min && date < new Date(field.min)) return false;\n if (field.max && date > new Date(field.max)) return false;\n return true;\n },\n {\n message:\n customMessage(field) ||\n `Date must be between ${field.min || \"any\"} and ${field.max || \"any\"}`,\n }\n );\n }\n\n // Handle required vs optional\n if (field.required) {\n // Add non-empty check for required fields\n return schema.refine(\n (val: unknown) => val !== undefined && val !== null && val !== \"\",\n { message: customMessage(field) || \"This field is required\" }\n );\n }\n\n return schema.optional().or(z.literal(\"\"));\n}\n\n/**\n * Generate schema for time field.\n */\nfunction generateTimeSchema(field: TimeFormField): z.ZodTypeAny {\n const schema = z\n .string()\n .regex(\n /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/,\n customMessage(field) || \"Invalid time format (HH:mm)\"\n );\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for hidden field.\n */\nfunction generateHiddenSchema(field: HiddenFormField): z.ZodTypeAny {\n const schema = z.string();\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n// ============================================================\n// Helper Functions\n// ============================================================\n\n/**\n * Apply required/optional to a schema.\n */\nfunction applyRequired(\n schema: z.ZodString,\n required?: boolean,\n errorMessage?: string\n): z.ZodTypeAny {\n if (required) {\n return schema.min(1, errorMessage || \"This field is required\");\n }\n\n return schema.optional().or(z.literal(\"\"));\n}\n\n// ============================================================\n// Data Transformation\n// ============================================================\n\n/**\n * Transform form data before validation.\n *\n * Handles type coercion and normalization:\n * - Converts string numbers to actual numbers\n * - Converts string booleans to actual booleans\n * - Trims string values\n * - Handles empty strings\n *\n * @param data - Raw form data\n * @param fields - Form field configurations\n * @returns Transformed data ready for validation\n */\nexport function transformFormData(\n data: Record<string, unknown>,\n fields: AnyFormField[]\n): Record<string, unknown> {\n const transformed: Record<string, unknown> = {};\n\n for (const field of fields) {\n const value = data[field.name];\n\n // Skip undefined values\n if (value === undefined) {\n continue;\n }\n\n // A plugin field's value passes through untransformed — the plugin owns its\n // value shape; only built-in types get type coercion.\n transformed[field.name] = isKnownFormField(field)\n ? transformFieldValue(value, field)\n : value;\n }\n\n return transformed;\n}\n\n/**\n * Transform a single field value based on its type.\n */\nfunction transformFieldValue(value: unknown, field: FormField): unknown {\n // Handle null/undefined\n if (value === null || value === undefined) {\n return value;\n }\n\n switch (field.type) {\n case \"number\":\n // Coerce to number\n if (typeof value === \"string\") {\n const num = parseFloat(value);\n return isNaN(num) ? value : num;\n }\n return value;\n\n case \"checkbox\":\n // Coerce to boolean\n if (typeof value === \"string\") {\n return value === \"true\" || value === \"1\" || value === \"on\";\n }\n return Boolean(value);\n\n case \"text\":\n case \"email\":\n case \"phone\":\n case \"url\":\n case \"textarea\":\n case \"hidden\":\n // Trim strings\n if (typeof value === \"string\") {\n return value.trim();\n }\n return value;\n\n case \"select\":\n // Ensure array for multi-select\n if (field.allowMultiple) {\n if (!Array.isArray(value)) {\n return value ? [value] : [];\n }\n }\n return value;\n\n default:\n return value;\n }\n}\n\n// ============================================================\n// Validation Helper\n// ============================================================\n\n/**\n * Validate form data against a form's field configuration.\n *\n * Combines transformation and validation in one step.\n *\n * @param data - Raw form data\n * @param fields - Form field configurations\n * @returns Zod safe parse result\n *\n * @example\n * ```typescript\n * const result = validateFormData({ name: 'John', email: 'john@example.com' }, fields);\n *\n * if (result.success) {\n * console.log('Valid data:', result.data);\n * } else {\n * console.log('Validation errors:', result.error.flatten());\n * }\n * ```\n */\nexport function validateFormData(\n data: Record<string, unknown>,\n fields: AnyFormField[]\n): z.ZodSafeParseResult<Record<string, unknown>> {\n const transformed = transformFormData(data, fields);\n const schema = generateZodSchema(fields);\n return schema.safeParse(transformed);\n}\n\n/**\n * Get validation errors as a flat object.\n *\n * @param result - Zod safe parse result\n * @returns Object mapping field names to error messages\n */\n/** A field that failed, with the machine code a wire error reports. */\nexport interface ValidationIssue {\n path: string;\n /** `REQUIRED` for an answer that is missing or empty, else `INVALID`. */\n code: \"REQUIRED\" | \"INVALID\";\n message: string;\n}\n\n/**\n * Whether the visitor supplied nothing at this field.\n *\n * The same four shapes the endpoint has always counted as blank. A `false`\n * checkbox and a `0` are answers, so neither is absent.\n */\nfunction isBlank(value: unknown): boolean {\n return (\n value === undefined ||\n value === null ||\n value === \"\" ||\n (Array.isArray(value) && value.length === 0)\n );\n}\n\n/**\n * The failures with their codes, first one per field.\n *\n * A code as well as a sentence, because the public submission error has always\n * carried one and a client uses it to tell \"you left this blank\" from \"this is\n * not an email\" without parsing English. Flattening every failure to `INVALID`\n * silently stopped required-field detection working for those clients.\n *\n * The code is decided by looking at the SUBMITTED VALUE, not at the Zod issue.\n * Zod's codes do not divide the same way: `too_small` covers a blank answer and\n * a four-character password under a minimum of eight, and `invalid_type` covers\n * an absent key and a string sent to a number field. Reading the code alone\n * told a visitor to fill in a field they had already filled in.\n */\nexport function getValidationIssues(\n result: z.ZodSafeParseResult<unknown>,\n submitted: Record<string, unknown> = {}\n): ValidationIssue[] {\n if (result.success) return [];\n\n const seen = new Set<string>();\n const issues: ValidationIssue[] = [];\n\n // Zod 4 renamed ZodError.errors to ZodError.issues.\n for (const issue of result.error.issues) {\n const path = issue.path.join(\".\");\n if (seen.has(path)) continue;\n seen.add(path);\n issues.push({\n path,\n // Read at the top level, which is the only depth a form field has: a\n // declared field is one key, so a dotted path can only come from inside a\n // value the visitor did supply.\n code: isBlank(submitted[path]) ? \"REQUIRED\" : \"INVALID\",\n message: issue.message,\n });\n }\n\n return issues;\n}\n\n/**\n * The same failures as a field-to-message map.\n *\n * Derived from {@link getValidationIssues} rather than walking the issues a\n * second time, so the two cannot disagree about which message belongs to a\n * field or which failure won when one field had several.\n */\nexport function getValidationErrors(\n result: z.ZodSafeParseResult<unknown>\n): Record<string, string> {\n return Object.fromEntries(\n getValidationIssues(result).map(issue => [issue.path, issue.message])\n );\n}\n","/**\n * Spam Detection\n *\n * Honeypot and rate limiting for form submissions.\n *\n * The honeypot is a pure read of the submitted payload. The rate limit is not:\n * it counts, and where the count lives decides whether the limit means\n * anything. It lives in the deployment's own {@link RateLimitStore}, the one\n * the REST and auth limiters already share, so `rateLimit.store` is configured\n * once and all three agree about what they are counting. A store private to\n * this module would count per process, and on a deployment that spans several\n * the effective limit becomes `configured x instances` -- a number the operator\n * never chose, that fails open, and only under load.\n *\n * @module handlers/spam-detection\n * @since 0.1.0\n */\n\nimport { RateLimiter, resolveRateLimitStore } from \"nextly\";\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Configuration for spam detection.\n */\nexport interface SpamCheckConfig {\n /**\n * Enable honeypot field detection.\n * When enabled, submissions with filled honeypot fields are marked as spam.\n */\n honeypot?: boolean;\n\n /**\n * Rate limiting configuration.\n */\n rateLimit?: {\n /** Maximum submissions allowed per window */\n maxSubmissions: number;\n /** Time window in milliseconds */\n windowMs: number;\n };\n\n /**\n * reCAPTCHA configuration (for future implementation).\n */\n recaptcha?: {\n enabled: boolean;\n secretKey?: string;\n scoreThreshold?: number;\n };\n}\n\n/**\n * Options for spam check.\n */\nexport interface SpamCheckOptions {\n /** Form submission data */\n data: Record<string, unknown>;\n\n /** Submitter's IP address */\n ipAddress?: string;\n\n /** Form slug for rate limiting key */\n formSlug: string;\n\n /** Spam protection configuration */\n config: SpamCheckConfig;\n}\n\n/**\n * Result of spam check.\n */\nexport interface SpamCheckResult {\n /** Whether the submission is detected as spam */\n isSpam: boolean;\n\n /** Reason for spam detection (for logging, not exposed to user) */\n reason?: \"honeypot\" | \"rate_limit\" | \"recaptcha\";\n\n /** Additional details (for logging) */\n details?: string;\n}\n\n// ============================================================\n// Main Spam Check Function\n// ============================================================\n\n/**\n * Check a form submission for spam indicators.\n *\n * Performs multiple spam checks based on configuration:\n * 1. Honeypot field detection\n * 2. Rate limiting by IP/form\n * 3. reCAPTCHA verification (future)\n *\n * @param options - Spam check options\n * @returns Spam check result\n *\n * @example\n * ```typescript\n * const result = await checkSpam({\n * data: formData,\n * ipAddress: req.ip,\n * formSlug: 'contact-form',\n * config: {\n * honeypot: true,\n * rateLimit: { maxSubmissions: 5, windowMs: 60000 },\n * },\n * });\n *\n * if (result.isSpam) {\n * // Silently reject (return fake success to bot)\n * return { success: true };\n * }\n * ```\n */\nexport async function checkSpam(\n options: SpamCheckOptions\n): Promise<SpamCheckResult> {\n const { data, ipAddress, formSlug, config } = options;\n\n // 1. Honeypot check\n if (config.honeypot) {\n const honeypotResult = checkHoneypot(data);\n if (honeypotResult.isSpam) {\n return honeypotResult;\n }\n }\n\n // 2. Rate limiting check\n if (config.rateLimit && ipAddress) {\n const rateLimitResult = await checkRateLimit(\n ipAddress,\n formSlug,\n config.rateLimit\n );\n if (rateLimitResult.isSpam) {\n return rateLimitResult;\n }\n }\n\n return { isSpam: false };\n}\n\n// ============================================================\n// Honeypot Detection\n// ============================================================\n\n/**\n * Standard honeypot field names to check.\n * These fields should be hidden from users but filled by bots.\n */\nconst HONEYPOT_FIELDS = [\n \"__honeypot\",\n \"_honeypot\",\n \"honeypot\",\n \"__hp\",\n \"_hp\",\n \"website\", // Common honeypot field name\n \"url_field\", // Another common honeypot\n \"fax_number\", // Rarely used by humans\n];\n\n/**\n * Check for honeypot field presence.\n *\n * Honeypot fields are hidden form fields that legitimate users\n * won't see or fill, but bots often fill automatically.\n *\n * @param data - Form submission data\n * @returns Spam check result\n */\nfunction checkHoneypot(data: Record<string, unknown>): SpamCheckResult {\n for (const fieldName of HONEYPOT_FIELDS) {\n const value = data[fieldName];\n\n // Check if honeypot field has a non-empty value\n if (value !== undefined && value !== null && value !== \"\") {\n let raw: string;\n if (typeof value === \"object\") {\n raw = JSON.stringify(value);\n } else {\n // eslint-disable-next-line @typescript-eslint/no-base-to-string -- value narrowed to primitive above; rule doesn't follow control flow on unknown\n raw = String(value);\n }\n const stringValue = raw.trim();\n if (stringValue.length > 0) {\n return {\n isSpam: true,\n reason: \"honeypot\",\n details: `Honeypot field \"${fieldName}\" was filled`,\n };\n }\n }\n }\n\n return { isSpam: false };\n}\n\n// ============================================================\n// Rate Limiting\n// ============================================================\n\n/**\n * Whether this address has submitted this form too often, recording the attempt.\n *\n * Keyed by form and address together, so a burst against one form does not\n * lock a visitor out of another. The window lives in the deployment's shared\n * store, so two processes serving the same visitor count once.\n *\n * An address of `unknown` is not a bucket to key on: every client whose address\n * could not be resolved would share one window, and the first bot to fill it\n * would lock out every visitor behind an untrusted proxy. The caller decides\n * what to do about an unidentifiable client; this counts only identified ones.\n */\nasync function checkRateLimit(\n ipAddress: string,\n formSlug: string,\n config: { maxSubmissions: number; windowMs: number }\n): Promise<SpamCheckResult> {\n const limiter = new RateLimiter(resolveRateLimitStore());\n const result = await limiter.check(\n `form-submit:${formSlug}:${ipAddress}`,\n config.maxSubmissions,\n config.windowMs\n );\n if (result.allowed) return { isSpam: false };\n return {\n isSpam: true,\n reason: \"rate_limit\",\n details: `Rate limit exceeded: ${config.maxSubmissions} per ${config.windowMs}ms`,\n };\n}\n\n// ============================================================\n// reCAPTCHA Support (Future Implementation)\n// ============================================================\n\n/**\n * Verify reCAPTCHA v3 token.\n *\n * Note: This is a placeholder for future implementation.\n * reCAPTCHA verification requires server-side API calls to Google.\n *\n * @param data - Form data containing reCAPTCHA token\n * @param config - reCAPTCHA configuration\n * @returns Spam check result\n */\n// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/require-await -- declared return type is `Promise<SpamCheckResult>`; placeholder for unimplemented Google siteverify fetch\nasync function verifyRecaptcha(\n _data: Record<string, unknown>,\n _config: { secretKey?: string; scoreThreshold?: number }\n): Promise<SpamCheckResult> {\n // Placeholder - always pass for now\n return { isSpam: false };\n}\n","/**\n * The public form endpoints, served by the plugin that owns the collections.\n *\n * `GET /api/forms`, `GET /api/forms/:slug` and `POST /api/forms/:slug/submit`\n * were served by the core dispatcher, which read collections named `forms` and\n * `form-submissions` in four hardcoded places. Core defines neither, so those\n * endpoints only ever worked when this plugin was installed, and they broke\n * outright on any install that renamed a collection through the plugin's own\n * overrides. They also validated a submission BEFORE the write seam judged it,\n * so a bot that tripped the honeypot while omitting a required field received a\n * distinguishable validation error and left no evidence behind.\n *\n * Contributed at the root mount, so the addresses are unchanged: a client\n * already calling `/api/forms/contact/submit` keeps calling it. Root routes are\n * consulted only where the built-in router declines, which is why core's\n * parser had to stop claiming `/forms` in the same change rather than a later\n * one. Left in place, it would answer first and these would never be reached.\n *\n * The bodies are the canonical envelopes, built with core's own helpers rather\n * than assembled here. A route that rebuilt the shape by hand would preserve\n * the URL while changing the contract underneath every client already calling\n * it, and would silently drop the post-commit warnings `respondAction` carries.\n *\n * @module routes/public-forms\n */\n\nimport {\n buildPaginatedResponse,\n clampLimit,\n formAvailability,\n NextlyError,\n NO_SUCH_FORM,\n respondAction,\n respondDoc,\n respondList,\n trustedClientIp,\n type FormAvailabilityInput,\n type PaginationMeta,\n type PluginRoute,\n type PluginRouteContext,\n} from \"nextly\";\n\nimport { submitForm } from \"../handlers/submit-form\";\nimport type { ResolvedFormBuilderConfig } from \"../types\";\n\n/**\n * These routes read and write as the anonymous visitor on the other end.\n *\n * `public: true` waives the ROUTE's authentication and says nothing about the\n * collections behind it. Elevating to `system` here would hand a form to any\n * caller on an install whose host set `formOverrides.access.read` to something\n * narrower, and accept a submission on one that closed creation, which is the\n * host's configuration being silently overridden rather than obeyed.\n */\nconst PUBLIC = { as: \"public\" } as const;\n\n/** How many forms a page of the public listing holds when none is asked for. */\nconst DEFAULT_LIST_LIMIT = 100;\n\n/** What a visitor is told when the form's author wrote no message of their own. */\nconst DEFAULT_SUCCESS_MESSAGE = \"Thank you for your submission!\";\n\n/**\n * The collection names this install actually uses.\n *\n * A host may rename either collection through the plugin's overrides, and\n * `ctx.self.collections` maps what the plugin DECLARED to what was registered.\n * Reading the declared name directly is what made the core endpoints fail on a\n * renamed install while the plugin's own export route kept working.\n */\nfunction installedSlugs(\n config: ResolvedFormBuilderConfig,\n self: PluginRouteContext[\"self\"]\n): { forms: string; submissions: string } {\n const declaredForms = config.formOverrides.slug;\n const declaredSubmissions = config.formSubmissionOverrides.slug;\n return {\n forms: self.collections[declaredForms] ?? declaredForms,\n submissions: self.collections[declaredSubmissions] ?? declaredSubmissions,\n };\n}\n\n/**\n * The config `submitForm` should read on THIS install.\n *\n * That helper takes the declared slugs from configuration, which is right for a\n * host calling it directly and wrong for a route, where the registered names\n * are knowable. Handing it a config whose slugs are already resolved keeps one\n * submission path rather than a second one that reads different tables.\n */\nfunction configForInstall(\n config: ResolvedFormBuilderConfig,\n self: PluginRouteContext[\"self\"]\n): ResolvedFormBuilderConfig {\n const slugs = installedSlugs(config, self);\n return {\n ...config,\n formOverrides: { ...config.formOverrides, slug: slugs.forms },\n formSubmissionOverrides: {\n ...config.formSubmissionOverrides,\n slug: slugs.submissions,\n },\n };\n}\n\n/** A positive integer from a query string, or `undefined` when it is not one. */\nfunction positiveInt(raw: string | null): number | undefined {\n if (raw === null) return undefined;\n const value = Number(raw);\n return Number.isInteger(value) && value > 0 ? value : undefined;\n}\n\n/**\n * The service's offset-based pagination, as the wire's page-based meta.\n *\n * The page arithmetic is `buildPaginatedResponse`'s, not a second copy of it.\n * Its edge cases are the ones this endpoint has always answered with and are\n * easy to get subtly wrong alone: an empty collection reports ONE page rather\n * than zero, and a page past the end reports the clamped page rather than the\n * one that was asked for, so `?page=100` on a single-page list cannot answer\n * \"page 100 of 1\".\n */\nfunction toPageMeta(pagination: {\n total: number;\n limit: number;\n offset: number;\n}): PaginationMeta {\n const limit = pagination.limit > 0 ? pagination.limit : DEFAULT_LIST_LIMIT;\n const built = buildPaginatedResponse([], {\n total: pagination.total,\n page: Math.floor(pagination.offset / limit) + 1,\n limit,\n });\n return {\n total: built.totalDocs,\n page: built.page,\n limit: built.limit,\n totalPages: built.totalPages,\n hasNext: built.hasNextPage,\n hasPrev: built.hasPrevPage,\n };\n}\n\nasync function listPublishedForms(\n req: Request,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig\n): Promise<Response> {\n const url = new URL(req.url);\n const slugs = installedSlugs(config, ctx.self);\n\n // Published only. The listing is the one surface anyone can enumerate, so a\n // draft or closed form must not appear in it; the by-slug read below is what\n // explains a closed form, and only to a caller who already holds its slug.\n const result = await ctx.services.collections.listEntries(\n slugs.forms,\n {\n where: { status: { equals: \"published\" } },\n pagination: {\n // Clamped HERE, with the canonical bound, so the limit that computes\n // the offset is the limit that comes back. The query service clamps to\n // 500 internally and reports the clamped value, while the offset is\n // built from what was asked for, so `?limit=1000&page=2` returned the\n // second 500-row slice and dividing the two reported page 3.\n limit: clampLimit(\n positiveInt(url.searchParams.get(\"limit\")) ?? DEFAULT_LIST_LIMIT\n ),\n page: positiveInt(url.searchParams.get(\"page\")) ?? 1,\n },\n },\n PUBLIC\n );\n\n return respondList(result.data ?? [], toPageMeta(result.pagination));\n}\n\nasync function readFormBySlug(\n slug: string,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig\n): Promise<Response> {\n const slugs = installedSlugs(config, ctx.self);\n\n // Fetched by slug alone, then judged. A status filter in the query would\n // answer 404 for a CLOSED form as readily as for one that never existed, and\n // those are different answers: a visitor following a link to a closed form is\n // owed the reason its author wrote.\n const result = await ctx.services.collections.listEntries(\n slugs.forms,\n { where: { slug: { equals: slug } }, pagination: { limit: 1 } },\n PUBLIC\n );\n\n const doc = result.data?.[0] as FormAvailabilityInput | undefined;\n const availability = formAvailability(doc);\n\n if (availability.kind === \"absent\") {\n // The same sentence every other door gives. The canonical \"Not found.\"\n // would make what a visitor is told depend on which client they used.\n throw NextlyError.notFound({\n message: NO_SUCH_FORM,\n logContext: { entity: \"form\", slug, reason: availability.reason },\n });\n }\n\n if (availability.kind === \"closed\") {\n // Only what a visitor at this address needs. The author wrote the message\n // FOR them; nothing else on the row is theirs, and a closed form renders a\n // sentence rather than fields.\n return respondDoc({\n slug,\n status: \"closed\",\n closedMessage: availability.message,\n });\n }\n\n return respondDoc(doc as unknown as Record<string, unknown>);\n}\n\n/**\n * The submitted answers, or the refusal for a body that carries none.\n *\n * A malformed body and an absent one are the same mistake to a caller, so both\n * answer the way core did: a validation error naming `data`, rather than the\n * parser's own exception surfacing as a 500.\n */\nasync function readSubmittedData(\n req: Request,\n slug: string\n): Promise<Record<string, unknown>> {\n let parsed: unknown;\n try {\n parsed = await req.json();\n } catch {\n parsed = undefined;\n }\n const body = parsed as { data?: Record<string, unknown> } | undefined;\n\n if (!body?.data || typeof body.data !== \"object\") {\n throw NextlyError.validation({\n errors: [\n {\n path: \"data\",\n code: \"MISSING_FIELD\",\n message: \"Request body must contain a 'data' object.\",\n },\n ],\n logContext: { slug },\n });\n }\n return body.data;\n}\n\n/**\n * The refusal an unaccepted outcome owes the caller.\n *\n * One place, so the status a given ending produces is read off a single list\n * rather than inferred from a message. `accepted` never reaches here: it is the\n * one outcome with a body rather than an error, and the caller returns it.\n */\nfunction refusalFor(\n result: Awaited<ReturnType<typeof submitForm>>,\n slug: string\n): NextlyError {\n const entity = { entity: \"form\", slug };\n switch (result.outcome) {\n case \"no-such-form\":\n return NextlyError.notFound({\n message: NO_SUCH_FORM,\n logContext: entity,\n });\n\n // A state conflict, not a validation failure. `validation` fixes the\n // canonical `error.message` to \"Validation failed.\" and nests the real one\n // under `error.data.errors[0]`, so a client reading the documented envelope\n // would be handed the author's explanation and never show it.\n case \"closed\":\n return NextlyError.conflict({\n reason: \"state\",\n message: result.error ?? NO_SUCH_FORM,\n logContext: { ...entity, reason: \"closed\" },\n });\n\n case \"duplicate\":\n return NextlyError.conflict({\n reason: \"state\",\n message: result.error ?? \"You have already submitted this form.\",\n logContext: { ...entity, reason: \"duplicate\" },\n });\n\n // The codes come from the issue list, not from the message map beside it.\n // A client tells \"you left this blank\" from \"this is not an email\" by the\n // code, and reporting every failure as `INVALID` stops that working while\n // looking correct in a status-only test.\n case \"invalid\":\n return NextlyError.validation({\n errors: (result.validationIssues ?? []).map(issue => ({\n path: issue.path,\n code: issue.code,\n message: issue.message,\n })),\n logContext: { slug },\n });\n\n // A typed refusal is re-thrown as itself. The collection may refuse a write\n // for a reason its host chose, and those carry their own status and field\n // detail; answering all of them with one internal error tells a visitor the\n // server broke when their submission was in fact answered deliberately.\n // Anything untyped stays a 500, which is what an unexpected failure is.\n default:\n if (result.cause instanceof NextlyError) return result.cause;\n return NextlyError.internal({\n logContext: { ...entity, reason: \"submission-failed\" },\n });\n }\n}\n\nasync function acceptSubmission(\n req: Request,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig,\n slug: string\n): Promise<Response> {\n const data = await readSubmittedData(req, slug);\n\n const result = await submitForm(\n {\n formSlug: slug,\n data,\n // The visitor's language, from `?locale=` on the submit URL — the same\n // place core's own routes read a locale on the wire — so a picked\n // redirect page answers with its URL in that language. A form on a\n // French page posts to `/api/forms/contact/submit?locale=fr` and is\n // sent to `/merci`; one that names no locale is answered in the\n // default, as before. An unconfigured code reads as the default rather\n // than refusing the submission: the visitor filled the form in, and a\n // wrong query string is not a reason to lose what they wrote.\n locale: new URL(req.url).searchParams.get(\"locale\") || undefined,\n // The request itself, so the write seam resolves the client under this\n // deployment's proxy-trust settings and judges this submission on the\n // same facts as one arriving through any other door.\n request: req,\n // The address for the audit column and the single-submission check,\n // resolved by the core rather than read off a header here, which would\n // record whatever the sender chose to claim and let one visitor pass as\n // many.\n metadata: {\n ipAddress: trustedClientIp(req) ?? undefined,\n userAgent: req.headers.get(\"user-agent\") ?? undefined,\n },\n access: PUBLIC,\n },\n {\n pluginContext: ctx,\n pluginConfig: configForInstall(config, ctx.self),\n }\n );\n\n if (result.outcome !== \"accepted\") throw refusalFor(result, slug);\n\n // One envelope for every accepted ending, including the two that are\n // deliberately indistinguishable from a refusal a bot could learn from: a\n // submission the honeypot flagged, and one the rate limiter refused.\n //\n // Both omit `submissionId`, because neither has a row a caller could then ask\n // about. That is a residual difference in the BODY, not in the status, and it\n // is the honest one: the alternatives are to invent an id that addresses\n // nothing, or to stop returning it to the real clients this endpoint has\n // always returned it to.\n return respondAction(\n result.successMessage ?? DEFAULT_SUCCESS_MESSAGE,\n {\n ...(result.submission ? { submissionId: result.submission.id } : {}),\n // Forwarded because this route is the only thing that can act on it. A\n // form configured to redirect on success resolves one here, and core's\n // endpoint dropped it, so the setting did nothing for any client posting\n // over HTTP. Additive, and present on a flagged submission too, so it\n // cannot be diffed against an accepted one.\n ...(result.redirect ? { redirect: result.redirect } : {}),\n },\n { status: 201 }\n );\n}\n\n/**\n * The three public form endpoints, at the addresses core used to serve.\n *\n * `public: true` on all three: a form is filled in by a visitor with no session\n * at all, which is the whole point of the feature.\n */\nexport function publicFormRoutes(\n config: ResolvedFormBuilderConfig\n): PluginRoute[] {\n return [\n {\n method: \"GET\",\n path: \"/forms\",\n mount: \"root\",\n public: true,\n // Collection documents, so their stored timestamps are presented in the\n // installation's timezone exactly as the built-in read did.\n formatTimestamps: true,\n handler: (req, ctx) => listPublishedForms(req, ctx, config),\n },\n {\n method: \"GET\",\n path: \"/forms/:slug\",\n mount: \"root\",\n public: true,\n formatTimestamps: true,\n handler: (_req, ctx) => readFormBySlug(ctx.params.slug, ctx, config),\n },\n {\n method: \"POST\",\n path: \"/forms/:slug/submit\",\n mount: \"root\",\n public: true,\n handler: (req, ctx) =>\n acceptSubmission(req, ctx, config, ctx.params.slug),\n },\n ];\n}\n","/**\n * Form Submission Handler\n *\n * Processes form submissions including validation, spam detection,\n * storage, and notification triggering.\n *\n * @module handlers/submit-form\n * @since 0.1.0\n */\n\nimport { isLocaleSelector } from \"@nextlyhq/plugin-sdk\";\nimport { formAvailability, NextlyError, NO_SUCH_FORM } from \"nextly\";\nimport type { PluginContext } from \"nextly\";\n\nimport { asFormDocument, asSubmissionDocument } from \"../document-shapes\";\nimport type {\n FormDocument,\n SubmissionDocument,\n ResolvedFormBuilderConfig,\n} from \"../types\";\nimport { normalizeFormSettings } from \"../utils/form-settings\";\nimport type { ValidationIssue } from \"../utils/generate-schema\";\nimport {\n applyRedirectPattern,\n documentReachability,\n parseRedirectReference,\n pickedDocumentField,\n type RedirectTargetDocument,\n type RedirectUrlPattern,\n} from \"../utils/redirect-target\";\n\nimport { asPluginSubmission, prepareSubmission } from \"./prepare-submission\";\nimport { checkSpam } from \"./spam-detection\";\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Options for submitting a form.\n */\nexport interface SubmitFormOptions {\n /** Form slug (URL-friendly identifier) */\n formSlug: string;\n\n /** Form submission data */\n data: Record<string, unknown>;\n\n /** Optional metadata about the submission */\n metadata?: {\n /** Submitter's IP address */\n ipAddress?: string;\n /** Submitter's user agent string */\n userAgent?: string;\n };\n\n /**\n * The HTTP request this submission arrived on.\n *\n * A route calling this helper is its own route, outside everything Nextly\n * pins a request around, so nothing else can supply it. Without it the write\n * reaches the submissions hooks looking like a seed or a job, and every rule\n * scoped to a visitor stands down: the rate limit above all.\n *\n * The request rather than an address, because the core resolves the client\n * from it under this deployment's proxy-trust settings. `metadata.ipAddress`\n * is a string the caller chose, which is the right shape for the audit column\n * it fills and the wrong shape for deciding who to throttle.\n */\n request?: Request;\n\n /**\n * Whose permissions the reads and the write are judged against.\n *\n * Defaults to `system`, so every existing caller behaves as before.\n */\n access?: SubmissionAccess;\n\n /**\n * The language the visitor submitted in.\n *\n * Decides which translation a picked redirect page is read in, and so which\n * URL the visitor is sent to: a page whose slug is `thanks` in English and\n * `merci` in French answered `/thanks` to everyone, because the target was\n * read with no locale and the default answered. The built-in route takes it\n * from `?locale=` on the submit URL, the same way core's own routes take a\n * locale on the wire; a host route passes whatever it resolved for the page\n * the form was on.\n *\n * A code that is not a configured locale reads as the default rather than\n * failing the submission, which the collection services decide. Absent means\n * the default, as it always has. The read wildcards (`all`, `*`) are not a\n * language a visitor can have submitted in and are treated as absent: a read\n * in \"every language\" answers a localized URL field with one value per\n * language, which no URL pattern can be filled from.\n */\n locale?: string;\n}\n\n/**\n * How this submission reaches the collections it touches.\n *\n * `system` is the default and is right for host code: the caller already\n * decided, and there is no visitor whose permissions could be consulted.\n *\n * `public` is for a route serving an anonymous visitor. It keeps the\n * collection's configured `access` rules in force, so a host that closed\n * submissions or restricted reading forms is obeyed rather than bypassed. The\n * former core endpoint ran this way, and elevating a public route to `system`\n * silently overrode that configuration.\n */\nexport type SubmissionAccess = { as: \"system\" } | { as: \"public\" };\n\n/**\n * Why a submission ended the way it did.\n *\n * `success` alone cannot carry this. An HTTP route has to answer 404 for a slug\n * nobody used, 409 for a form its author closed and 400 for data that did not\n * validate, and a boolean plus a human sentence cannot tell those apart without\n * matching on the sentence.\n *\n * `accepted` covers every ending a visitor is told succeeded, INCLUDING the two\n * that are deliberately indistinguishable from success: a submission the\n * honeypot flagged, and one the rate limiter refused. A caller must not try to\n * separate them, which is why they do not appear here as their own names.\n */\nexport type SubmitFormOutcome =\n | \"accepted\"\n | \"no-such-form\"\n | \"closed\"\n | \"invalid\"\n | \"duplicate\"\n | \"failed\";\n\n/**\n * Result of form submission.\n */\nexport interface SubmitFormResult {\n /** Whether the submission was successful */\n success: boolean;\n\n /**\n * Which ending this was, for a caller that has to map it to a status code.\n *\n * Required rather than optional, so a return path added later cannot omit it\n * and leave the HTTP route guessing from the message text.\n */\n outcome: SubmitFormOutcome;\n\n /** The created submission document (on success) */\n submission?: SubmissionDocument;\n\n /** Error message (on failure) */\n error?: string;\n\n /** Field-level validation errors */\n validationErrors?: Record<string, string>;\n\n /**\n * The same failures with their machine codes, for a caller building an HTTP\n * error. `validationErrors` above cannot carry one, so a route reading only\n * that has to invent a code and reports every blank answer as `INVALID`.\n */\n validationIssues?: ValidationIssue[];\n\n /** Redirect URL (if form configured for redirect on success) */\n redirect?: string;\n\n /**\n * The error that ended a `failed` submission, kept rather than flattened.\n *\n * A collection can refuse a write for reasons its host chose: a\n * `beforeValidate` hook throwing `NextlyError.validation`, a uniqueness\n * conflict. Those carry their own status and field detail, and reporting them\n * all as one internal error tells a visitor the server broke when in fact\n * their submission was answered deliberately.\n */\n cause?: unknown;\n\n /**\n * The toast the form's author wrote for a successful submission.\n *\n * Carried on the result rather than read from the form again by whoever\n * presents it: the HTTP route would otherwise have to fetch the form a second\n * time purely to learn a string this function already had in hand, and a\n * second read is a second chance to disagree about which form was submitted.\n *\n * Present on every `accepted` ending, including the ones a bot caused, so the\n * message cannot be used to tell an accepted submission from a flagged one.\n */\n successMessage?: string;\n}\n\n/**\n * Context for the submission handler.\n */\nexport interface SubmitFormContext {\n /** Plugin context with access to services */\n pluginContext: PluginContext;\n\n /** Resolved plugin configuration */\n pluginConfig: ResolvedFormBuilderConfig;\n}\n\n// ============================================================\n// Main Submission Handler\n// ============================================================\n\n/**\n * Process a form submission.\n *\n * This is the main entry point for handling form submissions.\n * It performs the following steps:\n *\n * 1. Fetch form configuration by slug\n * 2. Verify form status (must be \"published\")\n * 3. Transform and validate submission data\n * 4. Check for spam (honeypot, rate limiting)\n * 5. Create submission record in database\n * 6. Return result with redirect info if configured\n *\n * Note: Email notifications are triggered separately via collection\n * hooks in the submissions collection (see Phase 4.2).\n *\n * @param options - Submission options\n * @param context - Handler context with services\n * @returns Submission result\n *\n * @example\n * ```typescript\n * const result = await submitForm(\n * {\n * formSlug: 'contact-form',\n * data: { name: 'John', email: 'john@example.com', message: 'Hello!' },\n * metadata: { ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0...' },\n * },\n * { pluginContext, pluginConfig }\n * );\n *\n * if (result.success) {\n * console.log('Submission created:', result.submission?.id);\n * if (result.redirect) {\n * // Redirect user\n * }\n * } else {\n * console.error('Submission failed:', result.error);\n * }\n * ```\n */\nexport async function submitForm(\n options: SubmitFormOptions,\n context: SubmitFormContext\n): Promise<SubmitFormResult> {\n const { formSlug, data, metadata, request } = options;\n const access: SubmissionAccess = options.access ?? { as: \"system\" };\n const locale = visitorLocale(options.locale);\n const targetRead: TargetRead = { access, locale, request };\n const { pluginContext, pluginConfig } = context;\n const { collections } = pluginContext.services;\n const { logger } = pluginContext;\n\n try {\n // 1. Fetch form configuration\n const form = await fetchFormBySlug(\n formSlug,\n pluginConfig,\n pluginContext,\n access\n );\n\n if (!form) {\n logger.warn?.(\"Form submission attempted for non-existent form\", {\n formSlug,\n });\n return { success: false, outcome: \"no-such-form\", error: NO_SUCH_FORM };\n }\n\n // The destination is resolved from one place, because two endings ask for\n // it — the accepted submission and the throttled one disguised as accepted\n // — and two call sites naming the inputs separately is how one of them\n // comes to be answered in the wrong language while the other is right.\n const redirectFor = () =>\n resolveRedirectUrl(form, pluginConfig, pluginContext, targetRead);\n\n // 2. Check form status. The same reading the HTTP and Direct API paths do,\n // so what a visitor is told does not depend on which entry point their\n // client used.\n const availability = formAvailability(form);\n if (availability.kind !== \"open\") {\n logger.info?.(\"Form submission rejected - form not accepting\", {\n formSlug,\n status: form.status,\n availability: availability.kind,\n });\n // `absent` answers exactly as a slug nobody used does. Saying \"not\n // currently accepting submissions\" here would confirm the form exists,\n // which is the enumeration this whole reading exists to prevent — and it\n // would disagree with the REST and Direct API paths, which is how the\n // four came to give four different answers in the first place.\n return {\n success: false,\n outcome: availability.kind === \"closed\" ? \"closed\" : \"no-such-form\",\n error:\n availability.kind === \"closed\" ? availability.message : NO_SUCH_FORM,\n };\n }\n\n // 2b. Settings come through the one canonical reader — stored settings\n // may carry legacy keys from earlier builder versions.\n const settings = normalizeFormSettings(form.settings);\n\n // 2c. Single-submission forms: the same visitor (by IP) submits once.\n // Checked before spam so a repeat visitor gets the honest message\n // instead of an inexplicable success that stored nothing new. This is a\n // best-effort deterrent, not a guarantee: IPs are shared (NAT/proxies),\n // and the check-then-create pair is not atomic, so two truly concurrent\n // submissions can both pass — an airtight version needs a partial\n // unique index the portable field config cannot express yet.\n if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {\n // Without an identifier there is nothing to deduplicate on. Failing\n // closed would block every server-to-server integration, so the gate\n // stays open — loudly.\n logger.warn?.(\n \"Single-submission form received a submission without an IP — the restriction cannot apply\",\n { formSlug }\n );\n }\n if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {\n const existing = await collections.count(\n pluginConfig.formSubmissionOverrides.slug,\n {\n where: {\n form: { equals: form.id },\n ipAddress: { equals: metadata.ipAddress },\n status: { not_equals: \"spam\" },\n },\n },\n // Deliberately elevated, unlike the write below. This counts rows the\n // visitor is not entitled to see in order to refuse their second\n // submission; judged as the public it would count zero on any install\n // that hides submissions and the restriction would never apply.\n { as: \"system\" }\n );\n if (existing > 0) {\n logger.info?.(\"Repeat submission rejected (single-submission form)\", {\n formSlug,\n ipAddress: metadata.ipAddress,\n });\n return {\n success: false,\n outcome: \"duplicate\",\n error: \"You have already submitted this form.\",\n };\n }\n }\n\n // 3. Spam detection — BEFORE validation, so a bot that trips the trap\n // while also failing validation still receives the same fake success as\n // any other bot instead of a distinguishable validation error.\n // Honeypot fields are by definition NOT declared form fields, so the\n // probe is the raw payload minus the declared schema — a declared field\n // (e.g. a real \"website\" input) must never trip the trap.\n const declaredFieldNames = new Set(form.fields.map(field => field.name));\n const undeclaredData = Object.fromEntries(\n Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))\n );\n\n // The honeypot only, and only to decide whether to validate. It is a pure\n // read of the payload, so running it here and again at the write seam\n // costs nothing and cannot disagree. The RATE LIMIT is not asked here: it\n // counts, and a check with a side effect run twice counts one submission\n // twice. The seam owns that one, and owns the verdict written to the row.\n const spamResult = await checkSpam({\n data: undeclaredData,\n formSlug,\n config: {\n // Per-form overrides win where set; blank inherits the plugin config.\n honeypot:\n settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,\n },\n });\n\n const isContentSpam = spamResult.isSpam;\n\n // 4. Transform + validate. Content spam skips validation entirely — it\n // is stored for review exactly as the bot shaped it (declared fields\n // only, sanitized), and requiring validity would drop the evidence.\n if (isContentSpam) {\n logger.info?.(\"Spam submission detected — storing flagged\", {\n formSlug,\n reason: spamResult.reason,\n ipAddress: metadata?.ipAddress,\n });\n }\n\n const prepared = prepareSubmission({\n data,\n fields: form.fields,\n validate: !isContentSpam,\n });\n\n if (prepared.validationErrors) {\n logger.debug?.(\"Form validation failed\", {\n formSlug,\n errors: prepared.validationErrors,\n });\n return {\n success: false,\n outcome: \"invalid\",\n error: \"Validation failed\",\n validationErrors: prepared.validationErrors,\n validationIssues: prepared.validationIssues,\n };\n }\n\n const storedData = prepared.data;\n\n // 5. Create submission record. Content spam (honeypot/recaptcha) is\n // stored FLAGGED, never silently dropped: a false positive stays\n // reviewable in the Spam view and recoverable via \"Not spam\".\n const submissionData = {\n form: form.id,\n data: storedData,\n status: isContentSpam ? (\"spam\" as const) : (\"new\" as const),\n spamReason: isContentSpam ? (spamResult.reason ?? null) : null,\n ipAddress: metadata?.ipAddress || null,\n userAgent: metadata?.userAgent || null,\n submittedAt: new Date(),\n };\n\n // Marked as the plugin's own write for the length of the call, so the\n // write-seam hook knows a flagged row is evidence this handler chose to\n // keep rather than a caller asking for validation to be skipped.\n // Marked as this plugin's own write, so the write seam knows a flagged row\n // is evidence this handler chose to keep rather than a caller asking for\n // validation to be skipped. The form goes with it so the seam does not read\n // it a second time.\n let submission: Awaited<ReturnType<typeof collections.createEntry>>;\n try {\n submission = await collections.createEntry(\n pluginConfig.formSubmissionOverrides.slug,\n asPluginSubmission(submissionData, {\n keepAsEvidence: isContentSpam,\n form: { id: form.id, fields: form.fields },\n }),\n // Public form submission — create as system. No ambient user; an\n // empty context already resolves to system, but be explicit.\n //\n // The request travels with it so the seam judges this submission on the\n // same facts as one arriving through a Nextly route. Omitted by a caller\n // that has none, and then the seam correctly reads the write as\n // server-side work.\n //\n // `access` rather than a fixed `system`, so a route serving an anonymous\n // visitor is still judged against the collection's own create rule. A\n // host that closed public submissions configured that on the\n // collection, and a write elevated past it reports a 201 for a\n // submission they had refused.\n { ...access, ...(request ? { request } : {}) }\n );\n } catch (error) {\n // The seam refuses a submission over the limit, and refuses it the same\n // way at every door. This answers its own caller with a success anyway: a\n // browser is on the other end of it, and a bot told it was limited learns\n // the rate to sit under.\n //\n // `POST /api/forms/:slug/submit` reaches this. The route is contributed\n // by this plugin and calls this function, so the refusal a visitor sees\n // is decided here rather than being reconstructed as a 429 by an endpoint\n // that never consulted the seam.\n //\n // Nothing is stored. A limiter that wrote a row per refusal would hand an\n // attacker a way to fill the database with the very volume it exists to\n // refuse.\n if (NextlyError.isRateLimited(error)) {\n logger.info?.(\"Form submission refused (rate limit)\", { formSlug });\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n // The same destination an accepted submission gets. Returning early\n // without it left a throttled visitor on the form instead of the\n // page its author sends people to, and made the one response this\n // whole branch exists to disguise the only accepted one carrying no\n // redirect.\n redirect: await redirectFor(),\n };\n }\n throw error;\n }\n\n logger.info?.(\"Form submission created successfully\", {\n formSlug,\n submissionId: submission.item.id,\n flaggedAsSpam: isContentSpam,\n });\n\n // A post-commit hook cannot un-save the submission, so a failure there is\n // logged against the row rather than turned into a failed submission: the\n // notification email is the one that fails this way, and telling the\n // visitor their submission did not go through would have them send it\n // again.\n if (submission.warnings) {\n logger.warn?.(\"Form submission side effects failed\", {\n formSlug,\n submissionId: submission.item.id,\n warnings: submission.warnings,\n });\n }\n\n // Email notifications are sent via the afterCreate hook registered in\n // plugin.ts init(), so they fire for all submission paths (HTTP + direct).\n // The hook itself skips spam-flagged rows.\n\n // 6. Determine redirect URL. Spam gets the same success shape as a real\n // submission (minus the stored row reference) so bots can't diff the two.\n const redirect = await redirectFor();\n\n if (isContentSpam) {\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n redirect,\n };\n }\n\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n // The created ROW, not the envelope around it. Through the shared\n // conversion, which is where the unchecked step lives and says so; the\n // integration test reads `submission.id` off the result because nothing\n // at compile time will.\n submission: asSubmissionDocument(submission.item),\n redirect,\n };\n } catch (error) {\n logger.error?.(\"Form submission error\", {\n formSlug,\n error: error instanceof Error ? error.message : String(error),\n });\n\n return {\n success: false,\n outcome: \"failed\",\n error: \"An error occurred processing your submission. Please try again.\",\n cause: error,\n };\n }\n}\n\n// ============================================================\n// Helper Functions\n// ============================================================\n\n/**\n * Fetch a form by its slug via a service-level `where` query.\n *\n * @param slug - Form slug\n * @param pluginConfig - Plugin configuration\n * @param pluginContext - Plugin context\n * @returns Form document or null if not found\n */\nexport async function fetchFormBySlug(\n slug: string,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n access: SubmissionAccess = { as: \"system\" }\n): Promise<FormDocument | null> {\n const { collections } = pluginContext.services;\n\n // D56: resolve the form by slug via a service-level `where` query instead of\n // fetching every form and filtering client-side.\n //\n // Nothing is caught here. A read that THREW did not establish that the form\n // is absent, and answering `null` made a database outage or a broken schema\n // indistinguishable from a slug nobody has ever used: the caller was told\n // \"no such form\", which is a 404 a client may cache and a person may act on\n // by deleting the link. A failure now propagates and is answered as the\n // server error it is.\n const result = await collections.listEntries(\n pluginConfig.formOverrides.slug,\n { where: { slug: { equals: slug } }, pagination: { limit: 1 } },\n access\n );\n\n const form = result.data?.[0];\n return form ? asFormDocument(form) : null;\n}\n\n/**\n * Where a successful submission sends the visitor, or undefined for nowhere.\n *\n * A destination is either TYPED as a URL or PICKED as a document, and the two\n * are answered differently: a typed URL is returned verbatim, since it may\n * legitimately point off-site, while a picked document has to be read and put\n * through its collection's configured URL pattern.\n *\n * Two settings hold a picked document — `redirectPage` under the\n * \"Redirect to Page\" option, and `redirectRelation` under the URL option —\n * and both resolve through the same path, because they ask the same question.\n *\n * Every way of failing to produce a URL is logged. A form whose destination\n * cannot be built behaves exactly like one configured to stay put, so without\n * a line in the log the two are indistinguishable from the outside.\n */\nasync function resolveRedirectUrl(\n form: FormDocument,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<string | undefined> {\n const settings = form.settings;\n if (!settings) return undefined;\n\n // A typed URL is returned verbatim: it is the whole point of that option,\n // and it may legitimately point off-site.\n if (settings.confirmationType === \"redirect\" && settings.redirectUrl) {\n return settings.redirectUrl;\n }\n\n // Otherwise whichever key names a document, read through the SAME function\n // the save-time rule uses. Two readers here would let this path redirect to\n // a document the rule never inspected — which it did: the rule matched only\n // `relationship`, so a `redirect`-with-relation form was resolved here and\n // guarded nowhere.\n const field = pickedDocumentField(settings as Record<string, unknown>);\n if (!field) return undefined;\n\n return urlForPickedDocument(\n (settings as Record<string, unknown>)[field],\n form,\n pluginConfig,\n pluginContext,\n read\n );\n}\n\n/**\n * The URL for a picked document, or undefined with a reason in the log.\n *\n * Every branch reports. A form whose destination cannot be built behaves\n * exactly like one configured to stay put, so without a line here the two are\n * indistinguishable from outside — which is how a redirect that never fired\n * went unnoticed.\n */\nasync function urlForPickedDocument(\n stored: unknown,\n form: FormDocument,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<string | undefined> {\n const { logger } = pluginContext;\n const patterns = pluginConfig.redirectRelationships;\n const reference = parseRedirectReference(stored, Object.keys(patterns));\n\n if (!reference) {\n logger.warn?.(\"Form redirects to a page, but names no readable document\", {\n form: form.slug,\n });\n return undefined;\n }\n\n const pattern = patterns[reference.collection];\n if (!pattern) {\n logger.warn?.(\n \"Form redirects to a collection that is not in redirectRelationships\",\n { form: form.slug, collection: reference.collection }\n );\n return undefined;\n }\n\n const target = await readTarget(reference, form, pluginContext, read);\n if (!target) return undefined;\n\n // Reachability is re-decided HERE, not inherited from the save. Nothing runs\n // a forms hook when the target itself changes, so a page that was published\n // when the form was saved can be unpublished afterwards and the save-time\n // rule never sees it. Sending a visitor there produces exactly the \"page not\n // found\" that rule exists to prevent, so this degrades to no redirect — the\n // submission is still stored and still succeeds.\n // The target collection's own `localized` setting, resolved at plugin init.\n // Absent means it could not be read, which reports as undecided rather than\n // as \"not localized\" — dropping a redirect on that guess would strand a\n // destination a translation is still serving.\n if (\n documentReachability(\n target,\n pluginConfig.redirectTargetLocalization[reference.collection]\n ) === \"unreachable\"\n ) {\n logger.warn?.(\"Form redirects to a page that has never been published\", {\n form: form.slug,\n collection: reference.collection,\n target: reference.id,\n });\n return undefined;\n }\n\n return buildUrl(pattern, target, reference.collection, form, pluginContext);\n}\n\n/**\n * The pattern applied, or undefined with the reason logged.\n *\n * Separate because a configured pattern may be a FUNCTION — host code, which\n * can throw. Letting that escape would reach `submitForm`'s outer catch AFTER\n * the submission row exists, reporting a failure the caller may retry into a\n * duplicate of a submission that was already saved.\n */\nfunction buildUrl(\n pattern: RedirectUrlPattern,\n target: RedirectTargetDocument,\n collection: string,\n form: FormDocument,\n pluginContext: PluginContext\n): string | undefined {\n const { logger } = pluginContext;\n try {\n const url = applyRedirectPattern(pattern, target);\n if (!url) {\n logger.warn?.(\n \"Form redirect pattern could not be filled from the target document\",\n { form: form.slug, collection }\n );\n }\n return url;\n } catch (error) {\n logger.warn?.(\"Form redirect pattern threw while building a URL\", {\n form: form.slug,\n collection,\n error: error instanceof Error ? error.message : String(error),\n });\n return undefined;\n }\n}\n\n/** The target row, or undefined — a deleted target and a failed read differ. */\n/**\n * How the redirect target is read: as whom, in which language, and on which\n * request.\n *\n * As whom is the submission's own `access`, because the destination is a page\n * the VISITOR is about to be sent to, and the visitor's view of it is the one\n * that decides whether it is there. A public submission reads the target as a\n * public caller, which is scoped to published rows and — the part the locale\n * makes matter — to published TRANSLATIONS: a companion row still in draft is\n * withheld and the field falls back to the published default. Read as the\n * system instead, the trusted read admits every draft, and a French slug that\n * nobody has published yet becomes the URL a French visitor is sent to.\n *\n * A host that elevated the submission to `system` keeps the trusted read it\n * asked for, as before this carried a language.\n *\n * The request travels for the same reason the access does: a read that is\n * the visitor's should look like the visitor's to every hook on the target\n * collection. One that selects content by a tenant header, or applies only\n * to a browser, reads `ctx.req.http` — and a read carrying no request runs\n * those hooks as background work and builds a URL the visitor was never\n * meant to get.\n */\ninterface TargetRead {\n access: SubmissionAccess;\n locale: string | undefined;\n request: Request | undefined;\n}\n\n/**\n * The language a visitor submitted in, or nothing.\n *\n * A selector is not one: it asks the collection services for every\n * translation at once, which answers a localized URL field with one value per\n * language — a shape no URL pattern can be filled from — and the plugin\n * boundary refuses it outright, which after the submission is stored degrades\n * to no redirect. Neither is a language a form was filled in, so a selector\n * reads as no language named. Which spellings are selectors is the core's\n * question, asked of the core, so this cannot forward one the boundary has\n * since learned to refuse.\n */\nfunction visitorLocale(locale: string | undefined): string | undefined {\n if (locale === undefined || isLocaleSelector(locale)) return undefined;\n return locale;\n}\n\nasync function readTarget(\n reference: { collection: string; id: string },\n form: FormDocument,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<RedirectTargetDocument | undefined> {\n const { logger } = pluginContext;\n const context = {\n form: form.slug,\n collection: reference.collection,\n id: reference.id,\n };\n\n let row: unknown;\n try {\n // In the visitor's language, so a localized URL field answers with the\n // slug that page has THERE, and as the visitor, so it answers only with a\n // translation they can reach (see {@link TargetRead}). The fallback chain\n // stays in force: a page with no published translation for this locale\n // still has a URL, in the language the site falls back to, and sending\n // the visitor there beats sending them nowhere.\n row = await pluginContext.services.collections.findEntryById(\n reference.collection,\n reference.id,\n { ...read.access, locale: read.locale, request: read.request }\n );\n } catch (error) {\n // The submission already succeeded, so this degrades to \"no redirect\"\n // rather than failing the request — reported, because a broken read and a\n // deleted target are different problems with the same symptom.\n logger.warn?.(\"Form redirect target could not be read\", {\n ...context,\n error: error instanceof Error ? error.message : String(error),\n });\n return undefined;\n }\n\n if (!row || typeof row !== \"object\") {\n logger.warn?.(\"Form redirect target no longer exists\", context);\n return undefined;\n }\n return row as RedirectTargetDocument;\n}\n\n// ============================================================\n// Validation-Only Function\n// ============================================================\n\n/**\n * Validate form data without creating a submission.\n *\n * Useful for real-time validation in the frontend.\n *\n * @param formSlug - Form slug\n * @param data - Form data to validate\n * @param context - Handler context\n * @returns Validation result\n *\n * @example\n * ```typescript\n * const result = await validateSubmission(\n * 'contact-form',\n * { name: 'John', email: 'invalid-email' },\n * { pluginContext, pluginConfig }\n * );\n *\n * if (!result.valid) {\n * console.log('Validation errors:', result.errors);\n * }\n * ```\n */\nexport async function validateSubmission(\n formSlug: string,\n data: Record<string, unknown>,\n context: SubmitFormContext\n): Promise<{ valid: boolean; errors?: Record<string, string> }> {\n const { pluginContext, pluginConfig } = context;\n\n // Fetch form\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n\n if (!form) {\n return { valid: false, errors: { _form: \"Form not found\" } };\n }\n\n // The same rule storage applies, asked rather than restated. Restating it\n // meant this answered on the transformed but unsanitized value while the\n // write seam answers on the sanitized one, so a preflight check could call a\n // submission valid that storage then refused.\n const prepared = prepareSubmission({\n data,\n fields: form.fields,\n validate: true,\n });\n\n if (!prepared.validationErrors) {\n return { valid: true };\n }\n\n return {\n valid: false,\n errors: prepared.validationErrors,\n };\n}\n\n// ============================================================\n// Form Status Check\n// ============================================================\n\n/**\n * Check if a form is accepting submissions.\n *\n * @param formSlug - Form slug\n * @param context - Handler context\n * @returns Whether the form accepts submissions\n */\nexport async function isFormAcceptingSubmissions(\n formSlug: string,\n context: SubmitFormContext\n): Promise<boolean> {\n const { pluginContext, pluginConfig } = context;\n\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n\n if (!form) {\n return false;\n }\n\n return form.status === \"published\";\n}\n\n// ============================================================\n// Bulk Operations (Admin)\n// ============================================================\n\n/**\n * Get submission statistics for a form.\n *\n * Note: This function uses client-side filtering which works for\n * small-medium submission counts. For large-scale deployments,\n * consider using direct database queries with COUNT aggregation.\n *\n * @param formSlug - Form slug\n * @param context - Handler context\n * @returns Submission statistics\n */\nexport async function getFormSubmissionStats(\n formSlug: string,\n context: SubmitFormContext\n): Promise<{\n total: number;\n new: number;\n read: number;\n archived: number;\n} | null> {\n const { pluginContext, pluginConfig } = context;\n const { collections } = pluginContext.services;\n\n // Get form ID\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n if (!form) {\n return null;\n }\n\n try {\n const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;\n const sys = { as: \"system\" } as const;\n const base = { form: { equals: form.id } };\n\n // D56: count per status server-side instead of listing every\n // submission and counting client-side. Stats run as system (plugin-owned\n // aggregate over submissions for this form).\n const [total, newCount, read, archived] = await Promise.all([\n collections.count(submissionsSlug, { where: base }, sys),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"new\" } } },\n sys\n ),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"read\" } } },\n sys\n ),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"archived\" } } },\n sys\n ),\n ]);\n\n return { total, new: newCount, read, archived };\n } catch {\n return null;\n }\n}\n","/**\n * Form Configuration Validator\n *\n * Provides comprehensive validation for form configurations including:\n * - Slug validation (format, reserved names)\n * - Field name validation (format, duplicates)\n * - Field-specific validation (select/radio options, min/max constraints)\n * - Conditional logic validation\n * - Nested field validation\n *\n * @module config/validate-form\n * @since 1.0.0\n *\n * @example\n * ```typescript\n * import { validateFormConfig } from '@nextly/plugin-form-builder';\n *\n * const result = validateFormConfig(config);\n * if (!result.valid) {\n * console.error('Validation errors:', result.errors);\n * }\n * ```\n */\n\nimport { isPluginFieldTypeOnSurface } from \"nextly\";\n\nimport { BUILT_IN_FORM_FIELD_TYPES } from \"../types\";\nimport type { FormConfig, FormField, FormFieldType } from \"../types\";\nimport { parseRedirectReference } from \"../utils/redirect-reference\";\n\n// ============================================================\n// Validation Error Types\n// ============================================================\n\n/**\n * Error codes for form validation failures.\n * Used for programmatic error handling.\n */\nexport type FormValidationErrorCode =\n // Slug errors\n | \"SLUG_REQUIRED\"\n | \"SLUG_INVALID_TYPE\"\n | \"SLUG_TOO_SHORT\"\n | \"SLUG_TOO_LONG\"\n | \"SLUG_INVALID_FORMAT\"\n | \"SLUG_RESERVED\"\n // Field errors\n | \"FIELDS_REQUIRED\"\n | \"FIELDS_INVALID_TYPE\"\n | \"FIELDS_EMPTY\"\n | \"FIELD_NAME_REQUIRED\"\n | \"FIELD_NAME_INVALID_FORMAT\"\n | \"FIELD_NAME_DUPLICATE\"\n | \"FIELD_TYPE_REQUIRED\"\n | \"FIELD_TYPE_INVALID\"\n | \"FIELD_LABEL_REQUIRED\"\n // Field-specific errors\n | \"OPTIONS_REQUIRED\"\n | \"OPTIONS_EMPTY\"\n | \"MIN_GREATER_THAN_MAX\"\n | \"MIN_LENGTH_GREATER_THAN_MAX\"\n | \"FILE_MAX_SIZE_INVALID\"\n // Conditional logic errors\n | \"CONDITIONS_REQUIRED\"\n | \"CONDITION_FIELD_REQUIRED\"\n | \"CONDITION_COMPARISON_REQUIRED\"\n // Settings errors\n | \"SETTINGS_INVALID_TYPE\"\n | \"REDIRECT_URL_REQUIRED\"\n | \"REDIRECT_PAGE_REQUIRED\"\n // Access errors\n | \"ACCESS_INVALID_TYPE\"\n | \"ACCESS_FUNCTION_INVALID\";\n\n/**\n * A single validation error with path and context.\n *\n * @example\n * ```typescript\n * const error: FormValidationError = {\n * path: 'fields.email.validation',\n * message: \"Min length cannot be greater than max length\",\n * code: 'MIN_LENGTH_GREATER_THAN_MAX',\n * };\n * ```\n */\nexport interface FormValidationError {\n /**\n * Dot-notation path to the invalid property.\n * @example 'slug', 'fields.0.name', 'fields.email.options'\n */\n path: string;\n\n /**\n * Human-readable error message.\n */\n message: string;\n\n /**\n * Machine-readable error code for programmatic handling.\n */\n code: FormValidationErrorCode;\n}\n\n/**\n * Result of form config validation.\n */\nexport interface FormValidationResult {\n /**\n * Whether the configuration is valid.\n */\n valid: boolean;\n\n /**\n * Array of validation errors (empty if valid).\n */\n errors: FormValidationError[];\n}\n\n// ============================================================\n// Reserved Names\n// ============================================================\n\n/**\n * Reserved form slugs that cannot be used.\n * These are used by the system or have special meaning.\n */\nexport const RESERVED_FORM_SLUGS = [\n // API routes\n \"api\",\n \"submit\",\n \"submissions\",\n \"export\",\n // System slugs\n \"admin\",\n \"new\",\n \"create\",\n \"edit\",\n \"delete\",\n] as const;\n\n/**\n * Set for O(1) lookup of reserved slugs.\n */\nconst RESERVED_SLUGS_SET = new Set<string>(RESERVED_FORM_SLUGS);\n\n// ============================================================\n// Validation Patterns\n// ============================================================\n\n/**\n * Regex pattern for valid form slugs.\n * Must start with a lowercase letter, contain only lowercase letters,\n * numbers, hyphens, and underscores. Length: 1-50 chars.\n * Allows hyphens for URL-friendly slugs (e.g., 'contact-form').\n */\nconst SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;\n\n/**\n * Regex pattern for valid field names.\n * Must start with a letter (upper or lower), contain only letters,\n * numbers, and underscores. Supports camelCase (e.g., 'firstName').\n */\nconst FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;\n\n/**\n * All valid built-in form field types (plugin types validate via the registry).\n */\nconst VALID_FIELD_TYPES: readonly FormFieldType[] = BUILT_IN_FORM_FIELD_TYPES;\n\nconst VALID_FIELD_TYPES_SET = new Set<string>(VALID_FIELD_TYPES);\n\n// ============================================================\n// Validation Helper Functions\n// ============================================================\n\n/**\n * Checks if a string is a reserved form slug.\n */\nfunction isReservedSlug(slug: string): boolean {\n return RESERVED_SLUGS_SET.has(slug.toLowerCase());\n}\n\n/**\n * Validates a form slug.\n */\nfunction validateSlug(slug: unknown, errors: FormValidationError[]): void {\n const path = \"slug\";\n\n if (!slug) {\n errors.push({\n path,\n message: \"Form slug is required\",\n code: \"SLUG_REQUIRED\",\n });\n return;\n }\n\n if (typeof slug !== \"string\") {\n errors.push({\n path,\n message: \"Form slug must be a string\",\n code: \"SLUG_INVALID_TYPE\",\n });\n return;\n }\n\n if (slug.length < 1) {\n errors.push({\n path,\n message: \"Form slug must be at least 1 character\",\n code: \"SLUG_TOO_SHORT\",\n });\n }\n\n if (slug.length > 50) {\n errors.push({\n path,\n message: \"Form slug must be at most 50 characters\",\n code: \"SLUG_TOO_LONG\",\n });\n }\n\n if (!SLUG_PATTERN.test(slug)) {\n errors.push({\n path,\n message:\n \"Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores\",\n code: \"SLUG_INVALID_FORMAT\",\n });\n }\n\n if (isReservedSlug(slug)) {\n errors.push({\n path,\n message: `Form slug '${slug}' is reserved and cannot be used`,\n code: \"SLUG_RESERVED\",\n });\n }\n}\n\n/**\n * Validates a single field name.\n */\nfunction validateFieldName(\n name: unknown,\n path: string,\n errors: FormValidationError[],\n seenNames: Set<string>\n): void {\n if (!name) {\n errors.push({\n path: `${path}.name`,\n message: \"Field name is required\",\n code: \"FIELD_NAME_REQUIRED\",\n });\n return;\n }\n\n if (typeof name !== \"string\") {\n errors.push({\n path: `${path}.name`,\n message: \"Field name must be a string\",\n code: \"FIELD_NAME_REQUIRED\",\n });\n return;\n }\n\n if (!FIELD_NAME_PATTERN.test(name)) {\n errors.push({\n path: `${path}.name`,\n message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,\n code: \"FIELD_NAME_INVALID_FORMAT\",\n });\n }\n\n // Check for duplicates at this level\n const nameLower = name.toLowerCase();\n if (seenNames.has(nameLower)) {\n errors.push({\n path: `${path}.name`,\n message: `Duplicate field name '${name}'`,\n code: \"FIELD_NAME_DUPLICATE\",\n });\n } else {\n seenNames.add(nameLower);\n }\n}\n\n/**\n * Validates a single field label.\n */\nfunction validateFieldLabel(\n label: unknown,\n path: string,\n errors: FormValidationError[]\n): void {\n if (!label || (typeof label === \"string\" && label.trim() === \"\")) {\n errors.push({\n path: `${path}.label`,\n message: \"Field label is required\",\n code: \"FIELD_LABEL_REQUIRED\",\n });\n }\n}\n\n/**\n * Validates select/radio/checkbox-group field options.\n */\nfunction validateOptions(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n // Type assertion for fields with options\n const fieldWithOptions = field as {\n options?: Array<{ label: string; value: string }>;\n };\n const options = fieldWithOptions.options;\n\n if (!options) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field must have an 'options' array`,\n code: \"OPTIONS_REQUIRED\",\n });\n return;\n }\n\n if (!Array.isArray(options)) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field 'options' must be an array`,\n code: \"OPTIONS_REQUIRED\",\n });\n return;\n }\n\n if (options.length === 0) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field must have at least one option`,\n code: \"OPTIONS_EMPTY\",\n });\n }\n}\n\n/**\n * Validates conditional logic configuration.\n */\nfunction validateConditionalLogic(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n const { conditionalLogic } = field;\n\n if (!conditionalLogic || !conditionalLogic.enabled) {\n return;\n }\n\n if (\n !conditionalLogic.conditions ||\n conditionalLogic.conditions.length === 0\n ) {\n errors.push({\n path: `${path}.conditionalLogic.conditions`,\n message: \"Conditional logic must have at least one condition\",\n code: \"CONDITIONS_REQUIRED\",\n });\n return;\n }\n\n conditionalLogic.conditions.forEach((condition, index) => {\n const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;\n\n if (!condition.field) {\n errors.push({\n path: `${conditionPath}.field`,\n message: \"Condition must specify a field name\",\n code: \"CONDITION_FIELD_REQUIRED\",\n });\n }\n\n if (!condition.comparison) {\n errors.push({\n path: `${conditionPath}.comparison`,\n message: \"Condition must specify a comparison operator\",\n code: \"CONDITION_COMPARISON_REQUIRED\",\n });\n }\n });\n}\n\n/**\n * Validates field based on its type.\n */\nfunction validateFieldByType(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n switch (field.type) {\n case \"select\":\n case \"radio\":\n validateOptions(field, path, errors);\n break;\n\n case \"number\": {\n const numberField = field;\n if (numberField.validation) {\n const { min, max } = numberField.validation;\n if (min !== undefined && max !== undefined && min > max) {\n errors.push({\n path: `${path}.validation`,\n message: \"Min value cannot be greater than max value\",\n code: \"MIN_GREATER_THAN_MAX\",\n });\n }\n }\n break;\n }\n\n case \"text\":\n case \"textarea\": {\n const textField = field;\n if (textField.validation) {\n const { minLength, maxLength } = textField.validation;\n if (\n minLength !== undefined &&\n maxLength !== undefined &&\n minLength > maxLength\n ) {\n errors.push({\n path: `${path}.validation`,\n message: \"Min length cannot be greater than max length\",\n code: \"MIN_LENGTH_GREATER_THAN_MAX\",\n });\n }\n }\n break;\n }\n\n case \"file\": {\n const fileField = field;\n if (fileField.maxFileSize !== undefined && fileField.maxFileSize <= 0) {\n errors.push({\n path: `${path}.maxFileSize`,\n message: \"File maxFileSize must be a positive number\",\n code: \"FILE_MAX_SIZE_INVALID\",\n });\n }\n break;\n }\n }\n}\n\n/**\n * Validates a single field configuration.\n */\nfunction validateField(\n field: unknown,\n path: string,\n errors: FormValidationError[],\n seenNames: Set<string>\n): void {\n if (!field || typeof field !== \"object\") {\n return;\n }\n\n const f = field as Record<string, unknown>;\n\n // Validate field type\n const fieldType = f.type;\n if (!fieldType) {\n errors.push({\n path: `${path}.type`,\n message: \"Field type is required\",\n code: \"FIELD_TYPE_REQUIRED\",\n });\n return;\n }\n\n // A plugin may contribute a field type that opted into the forms surface; it\n // is as valid here as a built-in. Built-ins keep the fast allowlist; other\n // strings fall through to the registry, surface-gated.\n const isValidType =\n typeof fieldType === \"string\" &&\n (VALID_FIELD_TYPES_SET.has(fieldType) ||\n isPluginFieldTypeOnSurface(fieldType, \"forms\"));\n\n if (!isValidType) {\n let fieldTypeRepr: string;\n if (typeof fieldType === \"object\" && fieldType !== null) {\n fieldTypeRepr = JSON.stringify(fieldType);\n } else {\n // eslint-disable-next-line @typescript-eslint/no-base-to-string -- fieldType narrowed to primitive above; rule doesn't follow control flow on unknown\n fieldTypeRepr = String(fieldType);\n }\n errors.push({\n path: `${path}.type`,\n message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(\", \")}`,\n code: \"FIELD_TYPE_INVALID\",\n });\n return;\n }\n\n validateFieldName(f.name, path, errors, seenNames);\n\n // All fields except hidden require a label\n if (fieldType !== \"hidden\") {\n validateFieldLabel(f.label, path, errors);\n }\n\n // Type-specific validation\n validateFieldByType(field as FormField, path, errors);\n\n // Validate conditional logic if present\n if (f.conditionalLogic) {\n validateConditionalLogic(field as FormField, path, errors);\n }\n}\n\n/**\n * Validates an array of field configurations.\n */\nfunction validateFieldsArray(\n fields: unknown[],\n basePath: string,\n errors: FormValidationError[]\n): void {\n // Track field names for duplicate detection\n const seenNames = new Set<string>();\n\n fields.forEach((field, index) => {\n const fieldPath = `${basePath}[${index}]`;\n validateField(field, fieldPath, errors, seenNames);\n });\n}\n\n/**\n * Validates the top-level fields array.\n */\nfunction validateFields(fields: unknown, errors: FormValidationError[]): void {\n const path = \"fields\";\n\n if (!fields) {\n errors.push({\n path,\n message: \"Form must have at least one field\",\n code: \"FIELDS_REQUIRED\",\n });\n return;\n }\n\n if (!Array.isArray(fields)) {\n errors.push({\n path,\n message: \"Form fields must be an array\",\n code: \"FIELDS_INVALID_TYPE\",\n });\n return;\n }\n\n if (fields.length === 0) {\n errors.push({\n path,\n message: \"Form must have at least one field\",\n code: \"FIELDS_EMPTY\",\n });\n return;\n }\n\n validateFieldsArray(fields, path, errors);\n}\n\n/**\n * Validates form settings.\n */\nfunction validateSettings(\n settings: unknown,\n errors: FormValidationError[]\n): void {\n if (!settings) {\n return; // Settings are optional\n }\n\n if (typeof settings !== \"object\" || Array.isArray(settings)) {\n errors.push({\n path: \"settings\",\n message: \"Form settings must be an object\",\n code: \"SETTINGS_INVALID_TYPE\",\n });\n return;\n }\n\n const s = settings as Record<string, unknown>;\n\n // If confirmationType is \"redirect\", redirectUrl or redirectRelation is required\n if (s.confirmationType === \"redirect\") {\n if (!s.redirectUrl && !s.redirectRelation) {\n errors.push({\n path: \"settings.redirectUrl\",\n message:\n \"Redirect URL or redirect relation is required when confirmation type is 'redirect'\",\n code: \"REDIRECT_URL_REQUIRED\",\n });\n }\n }\n\n // A form set to redirect to a page needs the page. Without this the form\n // saves, the admin shows it as redirecting, and every submission finishes\n // with nowhere to go — the destination is missing at submit time, when\n // there is no one to tell.\n //\n // The SHAPE, not truthiness. `{}` and `{ relationTo: \"pages\" }` are truthy\n // and name no document, so a presence test admits precisely the values that\n // resolve to nothing. `null` for the collection list because this entry\n // point does not have the plugin config: a reference carrying its own\n // `relationTo` is checkable without it, and a bare id is not — which is the\n // honest limit of validating a form in isolation.\n if (\n s.confirmationType === \"relationship\" &&\n !namesADocument(s.redirectPage)\n ) {\n errors.push({\n path: \"settings.redirectPage\",\n message: \"A page is required when confirmation type is 'relationship'\",\n code: \"REDIRECT_PAGE_REQUIRED\",\n });\n }\n}\n\n/**\n * Whether a stored redirect value names a document at all.\n *\n * Derived from the shared reader rather than restating its rules, with an\n * empty collection list: that accepts every shape carrying its own\n * `relationTo` and rejects the ones naming nothing. A bare id is the case this\n * cannot judge here, since which collection it belongs to is plugin\n * configuration this entry point never sees — so it is accepted rather than\n * reported, because refusing a valid form is the worse error.\n */\nfunction namesADocument(value: unknown): boolean {\n if (typeof value === \"string\") return value.length > 0;\n return parseRedirectReference(value, []) !== null;\n}\n\n/**\n * Validates form access control.\n */\nfunction validateAccess(access: unknown, errors: FormValidationError[]): void {\n if (!access) {\n return; // Access is optional\n }\n\n if (typeof access !== \"object\" || Array.isArray(access)) {\n errors.push({\n path: \"access\",\n message: \"Access control must be an object\",\n code: \"ACCESS_INVALID_TYPE\",\n });\n return;\n }\n\n const accessObj = access as Record<string, unknown>;\n const validAccessKeys = [\"submit\", \"read\"];\n\n for (const key of validAccessKeys) {\n const fn = accessObj[key];\n if (fn !== undefined && typeof fn !== \"function\") {\n errors.push({\n path: `access.${key}`,\n message: `Access control '${key}' must be a function`,\n code: \"ACCESS_FUNCTION_INVALID\",\n });\n }\n }\n}\n\n// ============================================================\n// Main Validation Function\n// ============================================================\n\n/**\n * Validates a complete form configuration.\n *\n * This is the main entry point for form validation. It performs\n * comprehensive validation including:\n * - Slug format and reserved name checking\n * - Field name validation\n * - Field-specific validation (options, min/max, etc.)\n * - Duplicate field name detection\n * - Conditional logic validation\n * - Settings validation\n * - Access function type validation\n *\n * @param config - The form configuration to validate\n * @returns Validation result with any errors found\n *\n * @example\n * ```typescript\n * import { validateFormConfig } from '@nextly/plugin-form-builder';\n *\n * const config = {\n * slug: 'contact-form',\n * fields: [\n * { type: 'text', name: 'firstName', label: 'First Name' },\n * { type: 'email', name: 'email', label: 'Email', required: true },\n * ],\n * };\n *\n * const result = validateFormConfig(config);\n *\n * if (!result.valid) {\n * result.errors.forEach(err => {\n * console.error(`[${err.code}] ${err.path}: ${err.message}`);\n * });\n * }\n * ```\n */\nexport function validateFormConfig(config: FormConfig): FormValidationResult {\n const errors: FormValidationError[] = [];\n\n // Validate slug\n validateSlug(config.slug, errors);\n\n // Validate fields\n validateFields(config.fields, errors);\n\n // Validate settings\n validateSettings(config.settings, errors);\n\n // Validate access control\n validateAccess(config.access, errors);\n\n return {\n valid: errors.length === 0,\n errors,\n };\n}\n\n/**\n * Throws an error if the form configuration is invalid.\n *\n * This is a convenience wrapper around `validateFormConfig` that\n * throws a descriptive error instead of returning a result object.\n *\n * @param config - The form configuration to validate\n * @throws Error if validation fails with all error messages\n *\n * @example\n * ```typescript\n * import { assertValidFormConfig } from '@nextly/plugin-form-builder';\n *\n * // Throws if invalid\n * assertValidFormConfig(config);\n * ```\n */\nexport function assertValidFormConfig(config: FormConfig): void {\n const result = validateFormConfig(config);\n\n if (!result.valid) {\n const errorMessages = result.errors\n .map(err => ` - [${err.code}] ${err.path}: ${err.message}`)\n .join(\"\\n\");\n\n throw new Error(\n `Invalid form config for '${config.slug || \"unknown\"}':\\n${errorMessages}`\n );\n }\n}\n","/**\n * Form Field Helpers\n *\n * Convenient factory functions for creating form field configurations.\n * These helpers eliminate the need to manually specify the `type` property,\n * providing a cleaner, more ergonomic API for form definitions.\n *\n * @module config/field-helpers\n *\n * @example\n * ```typescript\n * import {\n * text,\n * email,\n * select,\n * checkbox,\n * option,\n * } from '@nextly/plugin-form-builder';\n *\n * const contactFormFields = [\n * text({ name: 'firstName', label: 'First Name', required: true }),\n * text({ name: 'lastName', label: 'Last Name', required: true }),\n * email({ name: 'email', label: 'Email Address', required: true }),\n * select({\n * name: 'subject',\n * label: 'Subject',\n * options: [\n * option('General Inquiry'),\n * option('Support Request'),\n * option('Feedback'),\n * ],\n * }),\n * checkbox({ name: 'subscribe', label: 'Subscribe to newsletter' }),\n * ];\n * ```\n */\n\nimport type {\n TextFormField,\n EmailFormField,\n NumberFormField,\n PhoneFormField,\n UrlFormField,\n TextareaFormField,\n SelectFormField,\n CheckboxFormField,\n RadioFormField,\n FileFormField,\n DateFormField,\n TimeFormField,\n HiddenFormField,\n} from \"../types\";\n\n// ============================================================\n// Utility Types\n// ============================================================\n\n/**\n * Option for select and radio fields.\n */\nexport interface FormFieldOption {\n label: string;\n value: string;\n}\n\n// ============================================================\n// Utility Helpers\n// ============================================================\n\n/**\n * Creates an option for select or radio fields.\n *\n * If no value is provided, the label is converted to lowercase with\n * spaces replaced by underscores.\n *\n * @param label - Display label for the option\n * @param value - Optional value (defaults to lowercase label with underscores)\n * @returns Option object with label and value\n *\n * @example\n * ```typescript\n * // Value derived from label\n * option('United States') // { label: 'United States', value: 'united_states' }\n *\n * // Explicit value\n * option('United States', 'us') // { label: 'United States', value: 'us' }\n *\n * // Usage in select field\n * select({\n * name: 'country',\n * label: 'Country',\n * options: [\n * option('United States', 'us'),\n * option('Canada', 'ca'),\n * option('Mexico', 'mx'),\n * ],\n * })\n * ```\n */\nexport const option = (label: string, value?: string): FormFieldOption => ({\n label,\n value: value ?? label.toLowerCase().replace(/\\s+/g, \"_\"),\n});\n\n// ============================================================\n// Text Input Field Helpers\n// ============================================================\n\n/**\n * Creates a text input field configuration.\n *\n * Text fields store simple string values with optional constraints\n * like min/max length and pattern validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete text field configuration\n *\n * @example\n * ```typescript\n * // Basic text field\n * text({ name: 'firstName', label: 'First Name', required: true })\n *\n * // Text with validation\n * text({\n * name: 'username',\n * label: 'Username',\n * required: true,\n * placeholder: 'Enter username',\n * validation: {\n * minLength: 3,\n * maxLength: 20,\n * pattern: '^[a-zA-Z0-9_]+$',\n * },\n * })\n * ```\n */\nexport const text = (config: Omit<TextFormField, \"type\">): TextFormField => ({\n ...config,\n type: \"text\",\n});\n\n/**\n * Creates an email input field configuration.\n *\n * Email fields provide built-in email format validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete email field configuration\n *\n * @example\n * ```typescript\n * // Basic email field\n * email({ name: 'email', label: 'Email Address', required: true })\n *\n * // Email with placeholder\n * email({\n * name: 'workEmail',\n * label: 'Work Email',\n * required: true,\n * placeholder: 'you@nextly.local',\n * })\n * ```\n */\nexport const email = (\n config: Omit<EmailFormField, \"type\">\n): EmailFormField => ({\n ...config,\n type: \"email\",\n});\n\n/**\n * Creates a phone input field configuration.\n *\n * Phone fields are optimized for phone number entry with\n * appropriate keyboard on mobile devices.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete phone field configuration\n *\n * @example\n * ```typescript\n * // Basic phone field\n * phone({ name: 'phone', label: 'Phone Number', required: true })\n *\n * // Phone with placeholder\n * phone({\n * name: 'mobile',\n * label: 'Mobile Number',\n * placeholder: '+1 (555) 123-4567',\n * })\n * ```\n */\nexport const phone = (\n config: Omit<PhoneFormField, \"type\">\n): PhoneFormField => ({\n ...config,\n type: \"phone\",\n});\n\n/**\n * Creates a URL input field configuration.\n *\n * URL fields provide built-in URL format validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete URL field configuration\n *\n * @example\n * ```typescript\n * // Basic URL field\n * url({ name: 'website', label: 'Website URL' })\n *\n * // URL with placeholder\n * url({\n * name: 'portfolio',\n * label: 'Portfolio URL',\n * placeholder: 'https://example.com',\n * })\n * ```\n */\nexport const url = (config: Omit<UrlFormField, \"type\">): UrlFormField => ({\n ...config,\n type: \"url\",\n});\n\n/**\n * Creates a textarea field configuration.\n *\n * Textarea fields provide multi-line text input with configurable\n * row count and length validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete textarea field configuration\n *\n * @example\n * ```typescript\n * // Basic textarea\n * textarea({ name: 'message', label: 'Message', required: true })\n *\n * // Textarea with rows and validation\n * textarea({\n * name: 'description',\n * label: 'Description',\n * rows: 5,\n * validation: {\n * minLength: 10,\n * maxLength: 500,\n * },\n * })\n * ```\n */\nexport const textarea = (\n config: Omit<TextareaFormField, \"type\">\n): TextareaFormField => ({\n ...config,\n type: \"textarea\",\n});\n\n// ============================================================\n// Numeric Field Helpers\n// ============================================================\n\n/**\n * Creates a number input field configuration.\n *\n * Number fields store numeric values with optional min/max/step\n * constraints.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete number field configuration\n *\n * @example\n * ```typescript\n * // Basic number field\n * number({ name: 'age', label: 'Age', required: true })\n *\n * // Number with range validation\n * number({\n * name: 'quantity',\n * label: 'Quantity',\n * required: true,\n * validation: {\n * min: 1,\n * max: 100,\n * step: 1,\n * },\n * })\n * ```\n */\nexport const number = (\n config: Omit<NumberFormField, \"type\">\n): NumberFormField => ({\n ...config,\n type: \"number\",\n});\n\n// ============================================================\n// Selection Field Helpers\n// ============================================================\n\n/**\n * Creates a select dropdown field configuration.\n *\n * Select fields display a dropdown list of options. Use the `option()`\n * helper to create options.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete select field configuration\n *\n * @example\n * ```typescript\n * // Basic select\n * select({\n * name: 'country',\n * label: 'Country',\n * required: true,\n * options: [\n * option('United States', 'us'),\n * option('Canada', 'ca'),\n * option('Mexico', 'mx'),\n * ],\n * })\n *\n * // Multi-select\n * select({\n * name: 'interests',\n * label: 'Interests',\n * allowMultiple: true,\n * options: [\n * option('Technology'),\n * option('Design'),\n * option('Business'),\n * ],\n * })\n * ```\n */\nexport const select = (\n config: Omit<SelectFormField, \"type\">\n): SelectFormField => ({\n ...config,\n type: \"select\",\n});\n\n/**\n * Creates a single checkbox field configuration.\n *\n * Checkbox fields store boolean values (true/false).\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete checkbox field configuration\n *\n * @example\n * ```typescript\n * // Terms agreement checkbox\n * checkbox({\n * name: 'agreeToTerms',\n * label: 'I agree to the terms and conditions',\n * required: true,\n * })\n *\n * // Newsletter subscription\n * checkbox({\n * name: 'subscribe',\n * label: 'Subscribe to our newsletter',\n * defaultValue: true,\n * })\n * ```\n */\nexport const checkbox = (\n config: Omit<CheckboxFormField, \"type\">\n): CheckboxFormField => ({\n ...config,\n type: \"checkbox\",\n});\n\n/**\n * Creates a radio group field configuration.\n *\n * Radio group fields allow selecting exactly one option from a list.\n * Use the `option()` helper to create options.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete radio field configuration\n *\n * @example\n * ```typescript\n * radio({\n * name: 'contactMethod',\n * label: 'Preferred Contact Method',\n * required: true,\n * options: [\n * option('Email', 'email'),\n * option('Phone', 'phone'),\n * option('SMS', 'sms'),\n * ],\n * })\n * ```\n */\nexport const radio = (\n config: Omit<RadioFormField, \"type\">\n): RadioFormField => ({\n ...config,\n type: \"radio\",\n});\n\n// ============================================================\n// File Field Helpers\n// ============================================================\n\n/**\n * Creates a file upload field configuration.\n *\n * File fields allow users to upload files with configurable\n * accepted types, size limits, and multiple file support.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete file field configuration\n *\n * @example\n * ```typescript\n * // Single file upload\n * file({\n * name: 'resume',\n * label: 'Upload Resume',\n * required: true,\n * accept: 'application/pdf,.doc,.docx',\n * maxFileSize: 5242880, // 5MB\n * })\n *\n * // Multiple images\n * file({\n * name: 'photos',\n * label: 'Upload Photos',\n * accept: 'image/*',\n * multiple: true,\n * maxFileSize: 10485760, // 10MB\n * })\n * ```\n */\nexport const file = (config: Omit<FileFormField, \"type\">): FileFormField => ({\n ...config,\n type: \"file\",\n});\n\n// ============================================================\n// Date/Time Field Helpers\n// ============================================================\n\n/**\n * Creates a date picker field configuration.\n *\n * Date fields provide a date picker with optional min/max constraints.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete date field configuration\n *\n * @example\n * ```typescript\n * // Basic date field\n * date({ name: 'birthdate', label: 'Date of Birth', required: true })\n *\n * // Date with range constraints\n * date({\n * name: 'eventDate',\n * label: 'Event Date',\n * required: true,\n * min: '2024-01-01',\n * max: '2024-12-31',\n * })\n * ```\n */\nexport const date = (config: Omit<DateFormField, \"type\">): DateFormField => ({\n ...config,\n type: \"date\",\n});\n\n/**\n * Creates a time picker field configuration.\n *\n * Time fields provide a time picker for selecting time values.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete time field configuration\n *\n * @example\n * ```typescript\n * // Basic time field\n * time({ name: 'appointmentTime', label: 'Preferred Time', required: true })\n *\n * // Time with default\n * time({\n * name: 'meetingTime',\n * label: 'Meeting Time',\n * defaultValue: '09:00',\n * })\n * ```\n */\nexport const time = (config: Omit<TimeFormField, \"type\">): TimeFormField => ({\n ...config,\n type: \"time\",\n});\n\n// ============================================================\n// Special Field Helpers\n// ============================================================\n\n/**\n * Creates a hidden field configuration.\n *\n * Hidden fields store values that are not visible to users but\n * are included in the submission data. Useful for tracking\n * metadata like source, campaign, or referrer.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete hidden field configuration\n *\n * @example\n * ```typescript\n * // Track form source\n * hidden({ name: 'source', label: 'Source', defaultValue: 'website' })\n *\n * // Campaign tracking\n * hidden({ name: 'campaign', label: 'Campaign', defaultValue: 'summer-sale' })\n * ```\n */\nexport const hidden = (\n config: Omit<HiddenFormField, \"type\">\n): HiddenFormField => ({\n ...config,\n type: \"hidden\",\n});\n","/**\n * Webhook Handler\n *\n * Triggers webhook notifications for form events.\n * Webhooks are fired asynchronously and do not block form submission.\n *\n * @module handlers/webhooks\n * @since 0.1.0\n */\n\nimport { safeFetch, ExternalUrlError } from \"nextly\";\n\nimport type {\n FormDocument,\n SubmissionDocument,\n WebhookConfig,\n WebhookEvent,\n} from \"../types\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Webhook payload sent to configured endpoints.\n */\nexport interface WebhookPayload {\n /** Event type that triggered the webhook */\n event: WebhookEvent;\n\n /** ISO 8601 timestamp when the event occurred */\n timestamp: string;\n\n /** Form information */\n form: {\n id: string;\n slug: string;\n name: string;\n };\n\n /** Submission information (full data or minimal based on config) */\n submission:\n | SubmissionDocument\n | {\n id: string;\n submittedAt: Date;\n };\n}\n\n/**\n * Options for triggering webhooks.\n */\nexport interface TriggerWebhooksOptions {\n /** Event type */\n event: WebhookEvent;\n\n /** Form document */\n form: FormDocument;\n\n /** Submission document */\n submission: SubmissionDocument;\n}\n\n/**\n * Result of a single webhook delivery attempt.\n */\nexport interface WebhookDeliveryResult {\n /** Webhook URL */\n url: string;\n\n /** Whether the delivery was successful */\n success: boolean;\n\n /** HTTP status code (if request completed) */\n statusCode?: number;\n\n /** Error message (if delivery failed) */\n error?: string;\n\n /** Time taken in milliseconds */\n durationMs: number;\n}\n\n/**\n * Result of triggering webhooks for an event.\n */\nexport interface TriggerWebhooksResult {\n /** Number of webhooks triggered */\n triggered: number;\n\n /** Number of successful deliveries */\n successful: number;\n\n /** Number of failed deliveries */\n failed: number;\n\n /** Individual delivery results */\n results: WebhookDeliveryResult[];\n}\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/** Default timeout for webhook requests (10 seconds) */\nconst WEBHOOK_TIMEOUT_MS = 10_000;\n\n/** Header name for webhook signature */\nconst SIGNATURE_HEADER = \"X-Webhook-Signature\";\n\n/** Header name for event type */\nconst EVENT_HEADER = \"X-Webhook-Event\";\n\n/** Header name for delivery ID (submission ID) */\nconst DELIVERY_ID_HEADER = \"X-Webhook-Delivery-ID\";\n\n// ============================================================================\n// Signature Generation\n// ============================================================================\n\n/**\n * Generate HMAC-SHA256 signature for webhook payload.\n *\n * @param payload - The webhook payload as a string\n * @param secret - The secret key for signing\n * @returns Signature in format \"sha256=<hex>\"\n */\nasync function generateSignature(\n payload: string,\n secret: string\n): Promise<string> {\n // Use Web Crypto API (works in both Node.js 18+ and browsers)\n const encoder = new TextEncoder();\n const keyData = encoder.encode(secret);\n const messageData = encoder.encode(payload);\n\n // Import the key\n const cryptoKey = await crypto.subtle.importKey(\n \"raw\",\n keyData,\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"]\n );\n\n // Sign the message\n const signature = await crypto.subtle.sign(\"HMAC\", cryptoKey, messageData);\n\n // Convert to hex string\n const hashArray = Array.from(new Uint8Array(signature));\n const hashHex = hashArray.map(b => b.toString(16).padStart(2, \"0\")).join(\"\");\n\n return `sha256=${hashHex}`;\n}\n\n// ============================================================================\n// Webhook Delivery\n// ============================================================================\n\n/**\n * Send a single webhook request.\n *\n * @param webhook - Webhook configuration\n * @param payload - Webhook payload\n * @returns Delivery result\n */\nasync function sendWebhook(\n webhook: WebhookConfig,\n payload: WebhookPayload\n): Promise<WebhookDeliveryResult> {\n const startTime = Date.now();\n const payloadString = JSON.stringify(payload);\n\n try {\n // Build headers\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n [EVENT_HEADER]: payload.event,\n [DELIVERY_ID_HEADER]: payload.submission.id,\n ...webhook.headers,\n };\n\n // Add signature if secret is configured\n if (webhook.secret) {\n headers[SIGNATURE_HEADER] = await generateSignature(\n payloadString,\n webhook.secret\n );\n }\n\n // Create abort controller for timeout\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);\n\n try {\n const response = await safeFetch(webhook.url, {\n method: webhook.method || \"POST\",\n headers,\n body: payloadString,\n signal: controller.signal,\n allowLocalhost: process.env.NODE_ENV !== \"production\",\n });\n\n clearTimeout(timeoutId);\n\n const durationMs = Date.now() - startTime;\n\n // Consider 2xx status codes as success\n if (response.ok) {\n return {\n url: webhook.url,\n success: true,\n statusCode: response.status,\n durationMs,\n };\n }\n\n return {\n url: webhook.url,\n success: false,\n statusCode: response.status,\n error: `HTTP ${response.status}: ${response.statusText}`,\n durationMs,\n };\n } finally {\n clearTimeout(timeoutId);\n }\n } catch (error) {\n const durationMs = Date.now() - startTime;\n\n // Handle abort (timeout)\n if (error instanceof Error && error.name === \"AbortError\") {\n return {\n url: webhook.url,\n success: false,\n error: `Timeout after ${WEBHOOK_TIMEOUT_MS}ms`,\n durationMs,\n };\n }\n\n // The webhook URL pointed at an internal / non-public address\n // and was refused before the request left the host. Surface as a\n // distinct delivery failure.\n if (error instanceof ExternalUrlError) {\n return {\n url: webhook.url,\n success: false,\n error: `Webhook URL rejected for SSRF safety: ${error.message}`,\n durationMs,\n };\n }\n\n // Handle other errors\n return {\n url: webhook.url,\n success: false,\n error: error instanceof Error ? error.message : String(error),\n durationMs,\n };\n }\n}\n\n// ============================================================================\n// Main Webhook Trigger Function\n// ============================================================================\n\n/**\n * Trigger webhooks for a form event.\n *\n * This function fires all matching webhooks asynchronously and does not\n * block form submission. Failed webhooks are logged but do not cause\n * submission failures.\n *\n * @param options - Trigger options\n * @returns Result of webhook triggering\n *\n * @example\n * ```typescript\n * // After creating a submission\n * const result = await triggerWebhooks({\n * event: 'submission.created',\n * form,\n * submission,\n * });\n *\n * console.log(`Triggered ${result.triggered} webhooks`);\n * console.log(`Successful: ${result.successful}, Failed: ${result.failed}`);\n * ```\n */\nexport async function triggerWebhooks(\n options: TriggerWebhooksOptions\n): Promise<TriggerWebhooksResult> {\n const { event, form, submission } = options;\n\n // Get webhooks configured on the form\n const webhooks = form.webhooks || [];\n\n // Filter webhooks that subscribe to this event\n const relevantWebhooks = webhooks.filter(w => w.events.includes(event));\n\n if (relevantWebhooks.length === 0) {\n return {\n triggered: 0,\n successful: 0,\n failed: 0,\n results: [],\n };\n }\n\n // Build payloads and send webhooks in parallel\n const deliveryPromises = relevantWebhooks.map(async webhook => {\n // Build payload based on includeData setting\n const payload: WebhookPayload = {\n event,\n timestamp: new Date().toISOString(),\n form: {\n id: form.id,\n slug: form.slug,\n name: form.name,\n },\n submission:\n webhook.includeData !== false\n ? submission\n : {\n id: submission.id,\n submittedAt: submission.submittedAt,\n },\n };\n\n return sendWebhook(webhook, payload);\n });\n\n // Wait for all webhooks to complete (or fail)\n const results = await Promise.all(deliveryPromises);\n\n // Count successes and failures\n const successful = results.filter(r => r.success).length;\n const failed = results.filter(r => !r.success).length;\n\n return {\n triggered: relevantWebhooks.length,\n successful,\n failed,\n results,\n };\n}\n\n// ============================================================================\n// Fire-and-Forget Helper\n// ============================================================================\n\n/**\n * Trigger webhooks without waiting for completion.\n *\n * This is the recommended way to call webhooks from submission handlers\n * as it doesn't block the response. Errors are logged but not thrown.\n *\n * @param options - Trigger options\n * @param logger - Optional logger for error reporting\n *\n * @example\n * ```typescript\n * // In submission handler - fire and forget\n * fireWebhooks(\n * { event: 'submission.created', form, submission },\n * pluginContext.logger\n * );\n *\n * // Continue with response immediately\n * return { success: true, submission };\n * ```\n */\nexport function fireWebhooks(\n options: TriggerWebhooksOptions,\n logger?: {\n info?: (message: string, data?: Record<string, unknown>) => void;\n warn?: (message: string, data?: Record<string, unknown>) => void;\n error?: (message: string, data?: Record<string, unknown>) => void;\n }\n): void {\n // Fire webhooks in background\n triggerWebhooks(options)\n .then(result => {\n if (result.triggered > 0) {\n if (result.failed > 0) {\n logger?.warn?.(\"Some webhooks failed\", {\n event: options.event,\n formSlug: options.form.slug,\n submissionId: options.submission.id,\n triggered: result.triggered,\n successful: result.successful,\n failed: result.failed,\n errors: result.results\n .filter(r => !r.success)\n .map(r => ({ url: r.url, error: r.error })),\n });\n } else {\n logger?.info?.(\"Webhooks triggered successfully\", {\n event: options.event,\n formSlug: options.form.slug,\n submissionId: options.submission.id,\n count: result.triggered,\n });\n }\n }\n })\n .catch(error => {\n // This shouldn't happen since triggerWebhooks catches all errors,\n // but handle it just in case\n logger?.error?.(\"Unexpected error triggering webhooks\", {\n event: options.event,\n formSlug: options.form.slug,\n submissionId: options.submission.id,\n error: error instanceof Error ? error.message : String(error),\n });\n });\n}\n\n// ============================================================================\n// Utility Functions\n// ============================================================================\n\n/**\n * Validate a webhook URL.\n *\n * @param url - URL to validate\n * @returns Whether the URL is valid for webhooks\n */\nexport function isValidWebhookUrl(url: string): boolean {\n try {\n const parsed = new URL(url);\n // Only allow HTTPS in production (HTTP allowed for localhost)\n if (\n parsed.protocol !== \"https:\" &&\n !parsed.hostname.match(/^(localhost|127\\.0\\.0\\.1)$/)\n ) {\n return false;\n }\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Get the list of supported webhook events.\n *\n * @returns Array of supported event types\n */\nexport function getSupportedWebhookEvents(): WebhookEvent[] {\n return [\"submission.created\", \"submission.updated\", \"submission.deleted\"];\n}\n","/**\n * @nextly/plugin-form-builder\n *\n * Visual form builder plugin for Nextly CMS.\n * Provides drag-and-drop form building, submission management,\n * email notifications, and spam protection.\n *\n * @packageDocumentation\n */\n\n// ============================================================\n// Main Plugin Export\n// ============================================================\n\n// ============================================================\n// Default Plugin Instance\n// ============================================================\n\n/**\n * Pre-configured form builder plugin for simple usage.\n *\n * This is a default instance with all defaults applied.\n * For customization, use `formBuilder()` instead.\n *\n * @example Simple usage - just add the plugin!\n * ```typescript\n * // nextly.config.ts\n * import { defineConfig } from 'nextly';\n * import { formBuilderPlugin } from '@nextly/plugin-form-builder';\n *\n * export default defineConfig({\n * plugins: [formBuilderPlugin],\n * collections: [Posts, Users, Media], // Your collections only\n * });\n * ```\n *\n * @example With customization\n * ```typescript\n * import { formBuilder } from '@nextly/plugin-form-builder';\n *\n * const myFormPlugin = formBuilder({\n * notifications: { defaultFrom: 'noreply@example.com' },\n * });\n *\n * export default defineConfig({\n * plugins: [myFormPlugin.plugin],\n * });\n * ```\n */\nimport { formBuilder } from \"./plugin\";\n\nexport {\n formBuilder,\n getFormBuilderConfig,\n type NextlyPlugin,\n type FormBuilderPluginResult,\n} from \"./plugin\";\nexport const formBuilderPlugin = formBuilder().plugin;\n\n// ============================================================\n// Type Exports\n// ============================================================\n\n// Plugin configuration types\nexport type {\n FormBuilderPluginOptions,\n ResolvedFormBuilderConfig,\n FieldBlockConfig,\n} from \"./types\";\n\n// Form field types (discriminated union)\nexport type {\n FormField,\n FormFieldType,\n FormFieldTypeId,\n AnyFormField,\n CustomFormField,\n BaseFormField,\n TextFormField,\n EmailFormField,\n NumberFormField,\n PhoneFormField,\n UrlFormField,\n TextareaFormField,\n SelectFormField,\n CheckboxFormField,\n RadioFormField,\n FileFormField,\n DateFormField,\n TimeFormField,\n HiddenFormField,\n} from \"./types\";\nexport { isKnownFormField, BUILT_IN_FORM_FIELD_TYPES } from \"./types\";\n\n// Form configuration types\nexport type {\n FormConfig,\n FormSettings,\n FormNotifications,\n FormAccess,\n FormHooks,\n FormHookContext,\n} from \"./types\";\n\n// Document types\nexport type { FormDocument, SubmissionDocument, FormSubmission } from \"./types\";\n\n// Notification rule type (stored on the form; edited by the builder UI)\nexport type { FormNotification, FormNotificationItem } from \"./types\";\n\n// Validation and conditional logic types\nexport type {\n ValidationRules,\n ConditionalLogic,\n ConditionalLogicCondition,\n} from \"./types\";\n\n// Email types\nexport type { EmailConfig } from \"./types\";\n\n// Webhook types\nexport type { WebhookConfig, WebhookEvent } from \"./types\";\n\n// ============================================================\n// Utility Exports\n// ============================================================\n\n// Type guards\nexport {\n isFormField,\n isFormFieldType,\n isTextFormField,\n isEmailFormField,\n isSelectFormField,\n isCheckboxFormField,\n isFileFormField,\n} from \"./types\";\n\n// ============================================================\n// Config Utilities (validation & defaults)\n// ============================================================\n\n// Validation utilities\nexport {\n validateFormConfig,\n assertValidFormConfig,\n RESERVED_FORM_SLUGS,\n type FormValidationError,\n type FormValidationErrorCode,\n type FormValidationResult,\n} from \"./config\";\n\n// Default application utilities\nexport {\n applyFormDefaults,\n applyFieldDefaults,\n createFormConfig,\n toTitleCase,\n pluralize,\n DEFAULT_FORM_SETTINGS,\n DEFAULT_NOTIFICATION_SETTINGS,\n} from \"./config\";\n\n// ============================================================\n// Field Helper Functions\n// ============================================================\n\n// Utility helper\nexport { option, type FormFieldOption } from \"./config\";\n\n// Text input field helpers\nexport { text, email, phone, url, textarea } from \"./config\";\n\n// Numeric field helpers\nexport { number } from \"./config\";\n\n// Selection field helpers\nexport { select, checkbox, radio } from \"./config\";\n\n// File field helpers\nexport { file } from \"./config\";\n\n// Date/time field helpers\nexport { date, time } from \"./config\";\n\n// Special field helpers\nexport { hidden } from \"./config\";\n\n// ============================================================\n// Conditional Logic Utilities\n// ============================================================\n\n// Conditional logic evaluation\nexport {\n evaluateConditions,\n isValidComparisonOperator,\n getSupportedComparisonOperators,\n type ComparisonOperator,\n} from \"./utils\";\n\n// ============================================================\n// Schema Generation & Validation Utilities\n// ============================================================\n\n// Zod schema generation for form validation\nexport {\n generateZodSchema,\n transformFormData,\n validateFormData,\n getValidationErrors,\n} from \"./utils\";\n\n// ============================================================\n// Export Utilities (CSV, JSON)\n// ============================================================\n\n// Export format utilities\nexport {\n exportToCSV,\n exportToJSON,\n formatExportValue,\n downloadFile,\n generateExportFilename,\n exportAndDownload,\n type CSVExportOptions,\n type JSONExportOptions,\n type ExportedJSON,\n} from \"./utils\";\n\n// ============================================================\n// Form Submission Handlers\n// ============================================================\n\n// Main submission handler\nexport {\n submitForm,\n validateSubmission,\n isFormAcceptingSubmissions,\n getFormSubmissionStats,\n type SubmitFormOptions,\n type SubmitFormResult,\n type SubmitFormContext,\n} from \"./handlers\";\n\n// Spam detection utilities\nexport {\n checkSpam,\n type SpamCheckConfig,\n type SpamCheckOptions,\n type SpamCheckResult,\n} from \"./handlers\";\n\n// Webhook utilities\nexport {\n triggerWebhooks,\n fireWebhooks,\n isValidWebhookUrl,\n getSupportedWebhookEvents,\n type WebhookPayload,\n type TriggerWebhooksOptions,\n type WebhookDeliveryResult,\n type TriggerWebhooksResult,\n} from \"./handlers\";\n\n// ============================================================\n// Collection Exports (for advanced usage)\n// ============================================================\n\nexport { formsCollection, submissionsCollection } from \"./collections\";\n"],"mappings":"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","names":["NextlyError","option","url","text","textarea","select","json","date","relationship","relationship","json","select","text","textarea","date","schema","date","formAvailability","NextlyError","NO_SUCH_FORM","NextlyError","NextlyError","url","url","formAvailability","NextlyError","NO_SUCH_FORM","url","NextlyError","email","text","textarea","select","checkbox","date","url"]}
1
+ {"version":3,"sources":["../src/plugin.ts","../src/collections/forms.ts","../src/utils/redirect-target.ts","../src/collections/access-defaults.ts","../src/collections/submissions.ts","../src/document-shapes.ts","../src/handlers/prepare-submission.ts","../src/utils/generate-schema.ts","../src/handlers/spam-detection.ts","../src/routes/public-forms.ts","../src/handlers/submit-form.ts","../src/config/validate-form.ts","../src/config/field-helpers.ts","../src/index.ts"],"sourcesContent":["/**\n * Form Builder Plugin\n *\n * Visual form builder with drag-and-drop UI, submission management,\n * email notifications, and spam protection.\n *\n * @module plugin\n * @since 0.1.0\n */\n\nimport { createRequire } from \"node:module\";\n\nimport {\n definePlugin,\n NextlyError,\n type PluginDefinition,\n} from \"@nextlyhq/plugin-sdk\";\nimport type { CollectionConfig } from \"nextly\";\n// Author against the SDK — the stable, experimental plugin boundary.\n\nimport { formsCollection } from \"./collections/forms\";\nimport { submissionsCollection } from \"./collections/submissions\";\nimport {\n asFormDocument,\n asSubmissionDocument,\n asSubmissionDocuments,\n} from \"./document-shapes\";\nimport {\n sameSubmittedPayload,\n submissionMarks,\n type SubmissionOriginMarks,\n prepareSubmission,\n} from \"./handlers/prepare-submission\";\nimport { checkSpam, type SpamCheckResult } from \"./handlers/spam-detection\";\nimport { publicFormRoutes } from \"./routes/public-forms\";\nimport type {\n AnyFormField,\n BeforeEmailFilterContext,\n FormNotification,\n FormBuilderPluginOptions,\n FormEmailNotification,\n ResolvedFormBuilderConfig,\n} from \"./types\";\nimport { evaluateSingleCondition } from \"./utils/evaluate-conditions\";\nimport { exportToCSV, generateExportFilename } from \"./utils/export-formats\";\nimport { parseFieldRef } from \"./utils/field-references\";\nimport { normalizeRedirectRelationships } from \"./utils/redirect-target\";\n\nexport type NextlyPlugin = PluginDefinition;\n\n// Read the version from package.json so it can never drift from the published\n// package. Node/config-side only (this module is not part of the admin bundle).\nconst { version: PLUGIN_VERSION } = createRequire(import.meta.url)(\n \"../package.json\"\n) as { version: string };\n\n/** The runtime instance passed to plugin hooks (type extracted from `init`'s parameter). */\ntype NextlyInstance = Parameters<NonNullable<NextlyPlugin[\"init\"]>>[0];\n\n/** Internal augmentation: we stash the resolved config on the nextly instance for later retrieval. */\ntype NextlyWithFormBuilderConfig = NextlyInstance & {\n __formBuilderConfig?: ResolvedFormBuilderConfig;\n};\n\n// ---------------------------------------------------------------------------\n// Configuration resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Merge user options with sensible defaults.\n */\nfunction resolveConfig(\n options: FormBuilderPluginOptions\n): ResolvedFormBuilderConfig {\n const formOverrides = options.formOverrides || {};\n const submissionOverrides = options.formSubmissionOverrides || {};\n\n return {\n formOverrides: {\n ...formOverrides,\n slug: formOverrides.slug || \"forms\",\n labels: {\n singular: formOverrides.labels?.singular || \"Form\",\n plural: formOverrides.labels?.plural || \"Forms\",\n },\n },\n\n formSubmissionOverrides: {\n ...submissionOverrides,\n slug: submissionOverrides.slug || \"form-submissions\",\n labels: {\n singular: submissionOverrides.labels?.singular || \"Submission\",\n plural: submissionOverrides.labels?.plural || \"Submissions\",\n },\n },\n\n fields: {\n text: options.fields?.text ?? true,\n email: options.fields?.email ?? true,\n number: options.fields?.number ?? true,\n phone: options.fields?.phone ?? true,\n url: options.fields?.url ?? true,\n textarea: options.fields?.textarea ?? true,\n select: options.fields?.select ?? true,\n checkbox: options.fields?.checkbox ?? true,\n radio: options.fields?.radio ?? true,\n file: options.fields?.file ?? true,\n date: options.fields?.date ?? true,\n time: options.fields?.time ?? true,\n hidden: options.fields?.hidden ?? true,\n },\n\n redirectRelationships: normalizeRedirectRelationships(\n options.redirectRelationships\n ),\n // Filled at `init` from each collection's own metadata. Empty until then,\n // and a slug that stays absent reads as unknown rather than as not\n // localized — see `ResolvedFormBuilderConfig`.\n redirectTargetLocalization: {},\n beforeEmail: options.beforeEmail,\n\n notifications: {\n defaultFrom: options.notifications?.defaultFrom,\n defaultToEmail: options.notifications?.defaultToEmail,\n enabled: options.notifications?.enabled ?? true,\n },\n\n spamProtection: {\n honeypot: options.spamProtection?.honeypot ?? true,\n recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },\n rateLimit: {\n maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,\n windowMs: options.spamProtection?.rateLimit?.windowMs ?? 60_000,\n },\n },\n\n uploads: {\n maxFileSize: options.uploads?.maxFileSize ?? 10_485_760, // 10 MB\n allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [\n \"image/*\",\n \"application/pdf\",\n \"text/*\",\n ],\n uploadCollection: options.uploads?.uploadCollection ?? \"media\",\n },\n\n features: {\n builder: options.features?.builder ?? true,\n conditionalLogic: options.features?.conditionalLogic ?? true,\n fileUploads: options.features?.fileUploads ?? true,\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\nexport interface FormBuilderPluginResult {\n /** Plugin definition — pass to `defineConfig({ plugins: [...] })`. */\n plugin: NextlyPlugin;\n /** Forms & Submissions collections (also auto-added by the plugin). */\n collections: CollectionConfig[];\n /** Resolved configuration with all defaults applied. */\n config: ResolvedFormBuilderConfig;\n}\n\n/**\n * Create a Form Builder plugin instance.\n *\n * @example Basic usage\n * ```ts\n * import { defineConfig } from \"nextly/config\";\n * import { formBuilder } from \"@nextlyhq/plugin-form-builder\";\n *\n * const fb = formBuilder();\n *\n * export default defineConfig({\n * plugins: [fb.plugin],\n * });\n * ```\n *\n * @example With options\n * ```ts\n * const fb = formBuilder({\n * notifications: { defaultFrom: \"noreply@example.com\" },\n * spamProtection: { honeypot: true },\n * formOverrides: {\n * slug: \"contact-forms\",\n * labels: { singular: \"Contact Form\", plural: \"Contact Forms\" },\n * },\n * });\n * ```\n */\nexport function formBuilder(\n options: FormBuilderPluginOptions = {}\n): FormBuilderPluginResult {\n const resolvedConfig = resolveConfig(options);\n\n const formsCol = formsCollection(resolvedConfig);\n const submissionsCol = submissionsCollection(resolvedConfig);\n\n const plugin = definePlugin({\n name: \"@nextlyhq/plugin-form-builder\",\n version: PLUGIN_VERSION,\n nextly: \">=0.0.2-alpha.21\",\n // Identity metadata for the admin plugins page, mirroring package.json.\n author: \"Nextly\",\n homepage: \"https://nextlyhq.com\",\n repository: \"https://github.com/nextlyhq/nextly\",\n license: \"MIT\",\n category: \"forms\",\n tags: [\"forms\", \"submissions\", \"email-notifications\"],\n\n // Declarative schema: the merged pipeline folds these into the\n // app schema — no manual `setup()` append needed. Just register the plugin.\n contributes: {\n collections: [formsCol, submissionsCol],\n // Custom permission — gates submission export beyond CRUD. The\n // canonical example third-party plugin authors copy.\n permissions: [\n {\n action: \"export\",\n resource: \"submissions\",\n label: \"Export Submissions\",\n description: \"Export form submissions as CSV/JSON\",\n // No `group`: the admin already files this under the plugin that\n // declared it, so naming the plugin again would nest it inside\n // itself. `group` is for a plugin with enough permissions to sort\n // its own into headings, which one is not.\n //\n // `danger` because the point of the permission is to take\n // submissions — names, emails, whatever a form asked for — out of\n // the site in a file.\n danger: true,\n },\n ],\n // HTTP route — exported at\n // /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by\n // default: gated by the custom `export-submissions` permission. Reads\n // via the secure-by-default service path as the authed user and\n // resolves its OWN slug through `ctx.self`. The canonical\n // contributes.routes example for third-party authors.\n routes: [\n // The public form endpoints, at the addresses core used to serve\n // before this plugin took them over. Root-mounted, so no caller's URL\n // changes; see `routes/public-forms`.\n ...publicFormRoutes(resolvedConfig),\n {\n method: \"GET\",\n path: \"/submissions/export\",\n requiredPermission: \"export-submissions\",\n handler: async (req, ctx) => {\n const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;\n const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;\n const url = new URL(req.url);\n const format = url.searchParams.get(\"format\") ?? \"json\";\n const formId = url.searchParams.get(\"form\");\n const status = url.searchParams.get(\"status\");\n\n // Spam stays out of exports unless it is explicitly requested —\n // an export is \"what people submitted\", not \"what bots sent\".\n const where: Record<string, unknown> = {};\n if (formId) where.form = { equals: formId };\n if (status) where.status = { equals: status };\n else where.status = { not_equals: \"spam\" };\n\n const opts = { as: \"user\", user: ctx.user ?? undefined } as const;\n\n // CSV needs its form BEFORE any submissions are read: the\n // columns come from one form's fields, and a malformed request\n // must fail fast instead of paginating the whole table first.\n let form: Record<string, unknown> | undefined;\n if (format === \"csv\") {\n if (!formId) {\n return Response.json(\n {\n error: {\n code: \"VALIDATION_ERROR\",\n message: \"CSV export requires a form parameter.\",\n },\n },\n { status: 400 }\n );\n }\n const formsSlugDeclared = resolvedConfig.formOverrides.slug;\n const formsSlug =\n ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;\n const formResult = await ctx.services.collections.listEntries(\n formsSlug,\n { where: { id: { equals: formId } } },\n opts\n );\n form = formResult.data[0];\n if (!form) {\n return Response.json(\n { error: { code: \"NOT_FOUND\", message: \"Form not found.\" } },\n { status: 404 }\n );\n }\n }\n\n // Page through the export with a hard ceiling: an unbounded loop\n // over a high-traffic form could hold the whole table in memory.\n // Hitting the ceiling is reported in a header, never silent.\n const MAX_EXPORT_ROWS = 50_000;\n const items: unknown[] = [];\n const pageSize = 200;\n let truncated = false;\n for (let page = 1; ; page += 1) {\n const result = await ctx.services.collections.listEntries(\n slug,\n { where, pagination: { limit: pageSize, page } },\n opts\n );\n items.push(...result.data);\n const pageWasFull = result.data.length === pageSize;\n if (items.length >= MAX_EXPORT_ROWS) {\n truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;\n items.length = MAX_EXPORT_ROWS;\n break;\n }\n if (!pageWasFull) break;\n }\n const truncationHeaders: Record<string, string> = truncated\n ? { \"X-Export-Truncated\": \"true\" }\n : {};\n\n if (format !== \"csv\" || !form) {\n return Response.json({ items }, { headers: truncationHeaders });\n }\n\n // The service returns parsed entries; the export helpers declare\n // the document shapes — the boundary cast is through unknown.\n const csv = exportToCSV(\n asSubmissionDocuments(items),\n asFormDocument(form)\n );\n const filename = generateExportFilename(\n typeof form.slug === \"string\" ? form.slug : \"form\",\n \"csv\"\n );\n return new Response(csv, {\n headers: {\n \"Content-Type\": \"text/csv; charset=utf-8\",\n \"Content-Disposition\": `attachment; filename=\"${filename}\"`,\n ...truncationHeaders,\n },\n });\n },\n },\n {\n // The builder UI reads the host's resolved field enable/disable map\n // from here, so the plugin option actually gates the type picker.\n // Options resolve server-side only; this is the one channel the\n // admin client has to them.\n method: \"GET\",\n path: \"/builder-config\",\n // Derived from the forms slug like the menu entry: with an\n // overridden slug, users hold read-<slug>, not read-forms.\n requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,\n handler: (_req, ctx) =>\n Promise.resolve(\n Response.json({\n fields: resolvedConfig.fields,\n // Notification defaults the builder surfaces: defaultToEmail\n // seeds a new form's first rule, defaultFrom renders as the\n // inherited sender placeholder.\n notifications: {\n defaultFrom: resolvedConfig.notifications.defaultFrom,\n defaultToEmail: resolvedConfig.notifications.defaultToEmail,\n },\n // Spam defaults the Settings tab surfaces, so per-form\n // override selects can show what \"inherit\" resolves to.\n spamProtection: {\n honeypot: resolvedConfig.spamProtection.honeypot,\n recaptchaEnabled:\n resolvedConfig.spamProtection.recaptcha?.enabled ?? false,\n },\n // Runtime-resolved collection slugs (through ctx.self, so a\n // framework .rename() is honored too) — admin components\n // never hardcode \"forms\"/\"form-submissions\".\n // Names only. A pattern may be a function, which does not\n // survive JSON and which the browser has no use for — the\n // admin picks a document, the server builds the URL.\n redirectCollections: Object.keys(\n resolvedConfig.redirectRelationships\n ),\n slugs: {\n forms:\n ctx.self.collections[resolvedConfig.formOverrides.slug] ??\n resolvedConfig.formOverrides.slug,\n submissions:\n ctx.self.collections[\n resolvedConfig.formSubmissionOverrides.slug\n ] ?? resolvedConfig.formSubmissionOverrides.slug,\n },\n })\n ),\n },\n ],\n },\n\n // Forms is a first-class destination, not a plugin detail: standalone\n // placement gives it its own main-rail icon after Media, and its\n // sub-sidebar lists the plugin's collections (Forms, Submissions) —\n // no separate menu contribution, so \"Forms\" exists exactly once.\n // Hosts preferring the Plugins section override placement in config.\n // (The old admin.settings.component that rendered a second full builder\n // at /admin/plugins/<slug> is gone; the collection Edit-view override\n // is the single FormBuilderView mount.)\n admin: {\n placement: \"standalone\",\n after: \"media\",\n order: 50,\n appearance: { icon: \"FileText\", label: \"Forms\" },\n description: \"Create and manage forms with submission tracking\",\n },\n\n // -- Init ----------------------------------------------------------------\n // Registers an afterCreate hook on submissions to send email notifications.\n async init(nextly: NextlyInstance) {\n // Resolve our OWN submissions slug through ctx.self, so the hook\n // follows a framework `.rename()` as well as our formSubmissionOverrides\n // option. The declared slug is the key; ctx.self maps it to the resolved\n // (possibly renamed) slug. Identity when not renamed.\n const declaredSubmissionsSlug =\n resolvedConfig.formSubmissionOverrides.slug;\n const submissionSlug =\n nextly.self.collections[declaredSubmissionsSlug] ??\n declaredSubmissionsSlug;\n\n // Hook/event subscriptions are idempotent across HMR — the platform\n // clears a plugin's prior subscriptions before re-init (B2), so the old\n // globalThis dedup guard is no longer needed.\n nextly.logger.info(\"Form Builder plugin initialized\", {\n formsCollection: resolvedConfig.formOverrides.slug,\n submissionsCollection: submissionSlug,\n enabledFields: Object.entries(resolvedConfig.fields)\n .filter(([, on]) => on)\n .map(([name]) => name),\n });\n\n // Ask each redirect target collection whether it is localized. The save\n // rule and the submission path both need it and neither can reach the\n // registry themselves: a collection hook is handed `req.nextly`, which\n // is the Direct API and carries no metadata accessor. Resolved once here\n // and written onto the config both of them already close over.\n //\n // Per collection rather than in one call: a slug that cannot be read\n // must stay ABSENT from the map, and a single failing lookup would\n // otherwise take its readable siblings with it.\n // Emptied first. `init` runs again on HMR and re-registration against the\n // SAME config object, so a lookup that succeeded on the previous boot and\n // fails on this one would leave its old answer standing while the catch\n // below believes it left the slug absent. A stale `false` then reads a\n // localized target as unreachable and drops its redirect; a stale `true`\n // lets a plain draft target through.\n for (const slug of Object.keys(\n resolvedConfig.redirectTargetLocalization\n )) {\n delete resolvedConfig.redirectTargetLocalization[slug];\n }\n\n // AWAITED, not fired and forgotten: a write reaching the save rule before\n // this resolves would find the map empty and read every target as\n // undecided, which is the refusal silently not happening.\n await Promise.all(\n Object.keys(resolvedConfig.redirectRelationships).map(\n async declared => {\n const slug = nextly.self.collections[declared] ?? declared;\n try {\n // `getCollection` reads registered metadata and does not consult\n // the context — its parameter is declared for a future ACL hook\n // and is unused today — so an empty context is the honest\n // argument rather than a fabricated user.\n const collection =\n await nextly.services.collections.getCollection(slug, {});\n const localized = (collection as { localized?: unknown })\n ?.localized;\n if (typeof localized === \"boolean\") {\n resolvedConfig.redirectTargetLocalization[declared] = localized;\n }\n } catch {\n // Left absent on purpose: unknown, not \"not localized\".\n nextly.logger.debug?.(\n \"Redirect target localization could not be read\",\n { collection: slug }\n );\n }\n }\n )\n );\n\n (nextly as NextlyWithFormBuilderConfig).__formBuilderConfig =\n resolvedConfig;\n\n // D63: run the user's beforeEmail config as a filter on the form-builder seam.\n if (resolvedConfig.beforeEmail) {\n nextly.filters.add(\n \"form-builder.beforeEmail\",\n (emails: FormEmailNotification[], ctx: BeforeEmailFilterContext) =>\n resolvedConfig.beforeEmail!({\n emails,\n // Boundary: loose runtime documents → the user's typed contract.\n form: asFormDocument(ctx.form),\n submission: asSubmissionDocument(ctx.submission),\n })\n );\n }\n\n // Every submission is brought to the form's own shape here, whatever\n // created it. Registered directly on the registry, like the two below, so\n // it runs for every API surface that writes one: the plugin's HTTP\n // handler, `nextly.forms.submit()`, and an admin creating a row by hand.\n //\n // The Direct API did none of this. A submission arriving that way was\n // stored with whatever keys the caller sent, values that never met the\n // form's schema, and markup intact, while the same submission over HTTP\n // was transformed, validated and sanitized. Putting the rule at the write\n // seam rather than in the second caller is what stops a third one\n // inheriting the gap.\n // The forms slug, resolved the way the submissions one above is. A host\n // that renames a contributed collection takes the declared slug out of\n // the registry, so reading the parent form by `formOverrides.slug` finds\n // nothing, and this hook would then refuse every submission on a renamed\n // install. Resolved once here and used by both readers.\n const declaredFormsSlug = resolvedConfig.formOverrides.slug;\n const formsSlug =\n nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;\n\n // `beforeChange` rather than `beforeCreate`: it is the last\n // COLLECTION-level mutating phase before the insert. Core runs stored\n // `beforeCreate` hooks after the code-registered ones, so a host hook\n // could put undeclared keys or markup back into a payload a\n // `beforeCreate` check had just passed.\n //\n // Field-level `beforeChange` hooks run after this one and are handed the\n // whole record, so a host that registers one on this collection can still\n // reshape `data` afterwards. No collection phase follows them to register\n // on, and core's own sanitizer sits earlier still, on `beforeCreate`.\n // This is the latest point a plugin can check from, which is not the same\n // as a point nothing can follow.\n nextly.hooks.on(\n \"beforeChange\",\n submissionSlug,\n async (context: unknown) =>\n prepareSubmissionForWrite(context, formsSlug, nextly)\n );\n\n // Register afterCreate hook for email notifications\n nextly.hooks.on(\n \"afterCreate\",\n submissionSlug,\n async (context: unknown) => {\n await handleSubmissionCreated(context, resolvedConfig, nextly);\n }\n );\n\n // Stamp admin edits of submitted data: changing what the visitor\n // submitted must leave a visible trace. Registered directly on the\n // registry (like the notification hook) so it runs for every API\n // surface that updates a submission.\n // Reads the patch as it arrived. Core runs this phase before\n // `beforeChange`, so the payload the write seam adds to a status-only\n // patch is not here yet and cannot be mistaken for an admin's edit.\n nextly.hooks.on(\"beforeUpdate\", submissionSlug, (context: unknown) => {\n const ctx = context as {\n data?: Record<string, unknown>;\n user?: { id?: string };\n };\n if (ctx.data && ctx.data.data !== undefined) {\n ctx.data.editedAt = new Date();\n ctx.data.editedBy = ctx.user?.id ?? null;\n }\n return ctx.data;\n });\n\n // Inject a real submissionCount into form reads (spam excluded — the\n // number answers \"how many people submitted\", not \"how many bots\").\n nextly.hooks.on(\"afterRead\", formsSlug, (context: unknown) =>\n injectSubmissionCount(context, submissionSlug, nextly)\n );\n },\n });\n\n return {\n plugin,\n collections: [formsCol, submissionsCol],\n config: resolvedConfig,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Utilities\n// ---------------------------------------------------------------------------\n\n/**\n * Retrieve the resolved Form Builder config from a Nextly plugin context.\n */\nexport function getFormBuilderConfig(\n nextly: unknown\n): ResolvedFormBuilderConfig | undefined {\n if (!nextly || typeof nextly !== \"object\") return undefined;\n return (nextly as Partial<NextlyWithFormBuilderConfig>).__formBuilderConfig;\n}\n\n/**\n * Resolve a `{{fieldName}}` template reference against submitted values.\n * Plain strings (e.g. email addresses) are returned as-is.\n */\nfunction resolveFieldRef(ref: string, data: Record<string, unknown>): string {\n // The pattern is shared with the admin, which decides whether a value is a\n // reference at all. Written twice, a value the editor accepted and this did\n // not would be delivered with its braces intact.\n const name = parseFieldRef(ref);\n if (name === null) return ref;\n const value = data[name];\n return typeof value === \"string\" ? value : \"\";\n}\n\n// ---------------------------------------------------------------------------\n// Attachment collection for file fields\n// ---------------------------------------------------------------------------\n\n/**\n * Scan form fields for file-upload fields marked `attachToEmail` and\n * collect the corresponding mediaIds from the submitted data. Returns\n * an array of `{ mediaId }` objects ready to pass as `attachments` to\n * `emailService.sendWithTemplate()`.\n *\n * Skips empty/missing values silently (optional file fields). Handles\n * both single-value and `multiple: true` file fields.\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function collectAttachmentInputs(\n fields: Array<Record<string, unknown>>,\n submittedData: Record<string, unknown>\n): Array<{ mediaId: string }> {\n const attachments: Array<{ mediaId: string }> = [];\n\n for (const field of fields) {\n if (field.type !== \"file\" || !field.attachToEmail) continue;\n\n const name = field.name as string;\n const value = submittedData[name];\n if (value == null) continue;\n\n if (Array.isArray(value)) {\n for (const item of value) {\n if (typeof item === \"string\" && item) {\n attachments.push({ mediaId: item });\n }\n }\n } else if (typeof value === \"string\" && value) {\n attachments.push({ mediaId: value });\n }\n }\n\n return attachments;\n}\n\n// ---------------------------------------------------------------------------\n// Submission notification handler\n// ---------------------------------------------------------------------------\n\n/**\n * Normalize a JSON column value from a raw DB row into an object. Hook\n * contexts carry the row as stored, so the value may be an already-parsed\n * object (jsonb dialects) or a serialized string (text-storage dialects).\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function parseJsonColumn(value: unknown): Record<string, unknown> {\n if (typeof value === \"string\") {\n try {\n const parsed: unknown = JSON.parse(value);\n return parsed && typeof parsed === \"object\" && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {};\n } catch {\n return {};\n }\n }\n return value && typeof value === \"object\" && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {};\n}\n\n/** Trim an address list and drop blanks; undefined when nothing remains. */\nfunction normalizeAddressList(\n addresses: readonly string[] | undefined\n): string[] | undefined {\n if (!Array.isArray(addresses)) return undefined;\n const cleaned = addresses\n .map(address => (typeof address === \"string\" ? address.trim() : \"\"))\n .filter(Boolean);\n return cleaned.length > 0 ? cleaned : undefined;\n}\n\n/** Why a notification rule produced no email for a given submission. */\nexport interface SkippedNotification {\n notificationId: string;\n reason: \"empty-recipient\" | \"condition-unmet\" | \"no-template\";\n}\n\n/**\n * Resolve a form's notification rules against one submission into outgoing\n * email descriptors, honoring everything a rule can configure: the send\n * condition gates the rule, the recipient/reply-to resolve `{{fieldName}}`\n * references, and the sender falls back from the rule's own address to the\n * plugin's `notifications.defaultFrom` (undefined lets the template/provider\n * default apply downstream).\n *\n * @internal Exported for testing — not part of the public plugin API.\n */\nexport function buildNotificationEmails(input: {\n notifications: readonly FormNotification[];\n submittedData: Record<string, unknown>;\n formName: unknown;\n submissionId: unknown;\n defaultFrom?: string;\n}): { emails: FormEmailNotification[]; skipped: SkippedNotification[] } {\n const { notifications, submittedData, formName, submissionId, defaultFrom } =\n input;\n\n const seen = new Set<string>();\n const emails: FormEmailNotification[] = [];\n const skipped: SkippedNotification[] = [];\n\n for (const notification of notifications) {\n if (!notification.enabled) continue;\n\n if (!notification.templateSlug) {\n skipped.push({ notificationId: notification.id, reason: \"no-template\" });\n continue;\n }\n\n // Deduplicate (UI can append duplicates on repeated saves). For id-less\n // rules (API-authored), only an exact structural duplicate counts — a\n // to+template key would silently drop distinct rules that share a\n // recipient and template but differ elsewhere (e.g. their condition).\n const key = notification.id || JSON.stringify(notification);\n if (seen.has(key)) continue;\n seen.add(key);\n\n // An unmet send condition skips the rule for this submission — an\n // expected state, never an error.\n if (\n notification.condition &&\n !evaluateSingleCondition(notification.condition, submittedData)\n ) {\n skipped.push({\n notificationId: notification.id,\n reason: \"condition-unmet\",\n });\n continue;\n }\n\n // Trim every address on the way out: whitespace-only values must count\n // as empty, and stray spaces must not reach the provider as headers.\n const to = (\n notification.recipientType === \"field\"\n ? resolveFieldRef(notification.to, submittedData)\n : notification.to\n ).trim();\n\n if (!to) {\n skipped.push({\n notificationId: notification.id,\n reason: \"empty-recipient\",\n });\n continue;\n }\n\n const cc = normalizeAddressList(notification.cc);\n const bcc = normalizeAddressList(notification.bcc);\n\n // Sender resolution: the rule's own address wins, then the plugin's\n // configured default; undefined defers to the template/provider chain.\n const from =\n notification.senderEmail?.trim() || defaultFrom?.trim() || undefined;\n\n // Reply-To resolves {{fieldName}} like recipients do; a reference to a\n // field the visitor left empty degrades to \"no Reply-To header\" rather\n // than an invalid address.\n const replyTo = notification.replyTo\n ? resolveFieldRef(notification.replyTo, submittedData).trim() || undefined\n : undefined;\n\n emails.push({\n to,\n templateSlug: notification.templateSlug,\n variables: {\n ...submittedData,\n formName,\n submissionId,\n },\n providerId: notification.providerId,\n from,\n replyTo,\n cc,\n bcc,\n notificationId: notification.id,\n });\n }\n\n return { emails, skipped };\n}\n\n/**\n * Send email notifications after a form submission is created.\n */\n/**\n * Bring an incoming submission to the shape its form declares.\n *\n * Runs on `beforeCreate` for the submissions collection, so it sees every write\n * rather than every caller. The cost is one read of the parent form per\n * submission: the HTTP handler has already loaded it and cannot hand it over,\n * because a hook receives the row and not the request. Submissions are written\n * one visitor at a time and the read is by primary key, which is the cheaper\n * side of the trade against a rule two callers have to remember.\n *\n * Refuses rather than storing when it cannot judge. A form it cannot read, or\n * one whose `fields` is not a list, leaves nothing to transform against, and\n * transforming against an empty list would project the submission down to `{}`\n * and store a row that says the visitor sent nothing. Silently emptying someone's\n * submission is worse than declining it.\n *\n * A row that names no form is passed through untouched. `form` is `required` on\n * the collection, so it is already refused a step later, and rejecting it here\n * would answer a question this hook was not asked.\n */\n/**\n * The parent form's id, however the row spells the relationship.\n *\n * A relationship arrives as an id string from a write and as a populated object\n * from a read, and both hooks on this collection have to cope with either.\n * Stated once so they cannot come to disagree about what counts as a reference.\n */\nfunction parentFormId(submission: Record<string, unknown>): string | null {\n const raw = submission.form;\n if (typeof raw === \"string\") return raw;\n if (raw && typeof raw === \"object\") {\n const maybeId = (raw as { id?: unknown }).id;\n if (typeof maybeId === \"string\") return maybeId;\n }\n return null;\n}\n\n/**\n * The submitted payload, as an object, or a refusal.\n *\n * `data` arrives as an object from every caller in this repository. A string is\n * read as the JSON a dialect stores rather than assumed to be one field's\n * value, and anything else is refused: that column is `required`, so an omitted\n * payload is meant to be refused, and substituting `{}` satisfied the check on\n * the way past. A form whose fields are all optional then accepted the empty\n * object and the row was stored.\n */\nfunction submittedPayload(raw: unknown): Record<string, unknown> {\n if (typeof raw === \"string\") {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n // Falls through to the refusal below.\n }\n } else if (raw && typeof raw === \"object\" && !Array.isArray(raw)) {\n return raw as Record<string, unknown>;\n }\n throw NextlyError.validation({\n errors: [\n {\n path: \"data\",\n code: \"INVALID\",\n message: \"Submission data must be an object.\",\n },\n ],\n });\n}\n\n/**\n * Whether an update has to be brought back to the form's shape.\n *\n * Two kinds of update do, and one does not.\n *\n * A patch that replaces `data` is replacing what the visitor sent, so it is\n * checked exactly as the create was. Exempting every update meant a caller who\n * may edit a submission could put undeclared keys, values the form's schema\n * rejects and markup into the row a moment after the create had refused to\n * accept them.\n *\n * A patch that touches `form` moves the row under a different schema, and the\n * payload it already holds is what has to satisfy that one. Such a patch\n * carries no `data` of its own, so a rule keyed on `data` alone let a caller\n * move a submission onto any form and leave behind a payload the form it now\n * belongs to rejects.\n *\n * A patch that takes the row out of spam is the third. An evidence row was\n * stored WITHOUT being validated, deliberately, so that a false positive stays\n * reviewable. The admin's \"Not spam\" action sends only a status, and letting it\n * through unchecked turned a payload the form rejects into an ordinary counted\n * submission. Leaving spam is the moment that payload has to satisfy the form,\n * and a row that cannot has to be corrected in the same update.\n *\n * An update that does none of these leaves nothing to check: an admin changing\n * a status between two non-spam values sends only that, and preparing an absent\n * payload would store an empty submission over a real one. The last two are\n * left alone for the same reason when the stored row did not reach this hook,\n * since there is then no payload to judge.\n */\nfunction updateNeedsChecking(\n submission: Record<string, unknown>,\n stored: Record<string, unknown> | undefined\n): boolean {\n if (submission.data !== undefined) return true;\n // Everything below judges the STORED payload, so there has to be one.\n if (stored?.data === undefined) return false;\n if (submission.form !== undefined) return true;\n return (\n stored.status === \"spam\" &&\n submission.status !== undefined &&\n submission.status !== \"spam\"\n );\n}\n\nexport async function prepareSubmissionForWrite(\n context: unknown,\n formsSlug: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown> | undefined> {\n const ctx = context as {\n data?: Record<string, unknown>;\n operation?: string;\n originalData?: Record<string, unknown>;\n user?: { id?: string };\n req?: { http?: { ip: string | null; method: string } };\n };\n const submission = ctx.data;\n if (!submission || typeof submission !== \"object\") return ctx.data;\n const stored = ctx.originalData;\n\n // A patch need not repeat the relationship, so the stored row is what says\n // which form this payload has to match.\n const formId =\n parentFormId(submission) ?? (stored ? parentFormId(stored) : null);\n if (!formId) return ctx.data;\n\n if (ctx.operation !== \"create\" && !updateNeedsChecking(submission, stored)) {\n return ctx.data;\n }\n\n const incoming = submittedPayload(\n submission.data !== undefined ? submission.data : stored?.data\n );\n\n // Read off the row itself, so it describes this write and no other.\n const marks = submissionMarks(submission);\n\n const form = await formToCheckAgainst(marks, formsSlug, formId, nextly);\n const fields = form.fields as AnyFormField[];\n\n // Spam is judged HERE, at the seam every door passes through, rather than in\n // the route: the submissions collection grants public create, so the generic\n // collection create is a second public door and the Direct API a third. A\n // rule that lives in one of them guards one of them.\n //\n // Only when a request produced this write. `ctx.req.http` is absent for a\n // seed, an import or a job, and a rule aimed at a visitor must not judge a\n // server that is importing ten thousand rows as one.\n const spam =\n ctx.operation === \"create\" && ctx.req?.http\n ? await judgeSubmission(incoming, form, ctx.req.http, nextly)\n : undefined;\n\n if (spam?.reason === \"rate_limit\") {\n // Refused, not stored: a limiter that wrote a row per refusal would turn\n // volume into a database it fills for the attacker. The plugin's own route\n // answers its visitor with a success anyway, so a bot learns nothing from\n // it; the other doors are machine-facing and get the honest 429.\n throw NextlyError.rateLimited();\n }\n\n // Content spam keeps its evidence. The handler stores a honeypot or reCAPTCHA\n // hit flagged rather than dropping it, so a false positive stays recoverable,\n // and requiring it to be valid would throw away the thing being reviewed. It\n // is still transformed and sanitized.\n // Leniency is a fact about the CALL, not about the row. Reading it off\n // `status` made it caller-controlled: the collection grants public create and\n // nothing restricts that field, so anyone could post `status: \"spam\"` and\n // switch validation off for their own row.\n const prepared = prepareSubmission({\n data: incoming,\n fields,\n validate: marks?.keepAsEvidence !== true && spam === undefined,\n });\n\n if (prepared.validationErrors) {\n throw NextlyError.validation({\n errors: Object.entries(prepared.validationErrors).map(\n ([path, message]) => ({ path, code: \"INVALID\", message })\n ),\n });\n }\n\n ctx.data = { ...submission, data: prepared.data };\n if (spam) {\n // Flagged, never dropped: a false positive stays reviewable in the Spam\n // view and recoverable through \"Not spam\". Written here rather than trusted\n // from the payload, because the collection grants public create and nothing\n // restricts these fields, so a caller could otherwise mark its own row.\n ctx.data.status = \"spam\";\n ctx.data.spamReason = spam.reason ?? null;\n }\n if (ctx.operation !== \"create\" && submission.data === undefined) {\n stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);\n }\n return ctx.data;\n}\n\n/**\n * The fields a submission has to satisfy.\n *\n * The handler that already read this form hands it over rather than have the\n * write read it a second time. That read is not free: `findEntryById` runs the\n * forms collection's `afterRead` hooks, and this plugin registers one that\n * COUNTs the form's submissions, so a write was paying for a count of every\n * write before it. Only ever the form this row names, because the id has to\n * match.\n */\nasync function formToCheckAgainst(\n marks: SubmissionOriginMarks | undefined,\n formsSlug: string,\n formId: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown>> {\n const handedOver = marks?.form?.id === formId ? marks.form : null;\n const form = handedOver ?? (await fetchParentForm(formsSlug, formId, nextly));\n if (!form || !Array.isArray(form.fields)) {\n throw NextlyError.validation({\n errors: [\n {\n path: \"form\",\n code: \"INVALID\",\n message:\n \"The form this submission belongs to could not be read, so the submission could not be checked against it.\",\n },\n ],\n });\n }\n return form;\n}\n\n/**\n * The part of a rate-limit key that names the form.\n *\n * The ID, never the slug. A door that hands the seam the form it already read\n * carries `{ id, fields }`, and a door that makes the seam fetch it carries the\n * whole row: keyed on whatever each happens to have, one form gets one window\n * per door and a caller alternating between two doors spends two budgets.\n *\n * Only a string identifies a form. A row whose id is unusable is limited as its\n * own bucket rather than silently joining a neighbour's.\n */\nfunction rateLimitKeyFor(form: Record<string, unknown>): string {\n const { id } = form;\n return typeof id === \"string\" && id.length > 0 ? id : \"unidentified-form\";\n}\n\n/**\n * Whether this submission looks like a bot's, and why.\n *\n * `undefined` means nothing was detected, which is not the same as \"no rule\n * ran\": a form that disables the honeypot and a deployment that configures no\n * rate limit both answer that way, and both mean the submission is stored as an\n * ordinary one.\n *\n * The address comes from the core, resolved under the deployment's proxy-trust\n * settings, so it is the closest untrusted hop rather than whatever the sender\n * put in `x-forwarded-for`. `null` means no address could be trusted, and the\n * rate limit does not run: keying it on a placeholder would put every\n * unidentifiable client in one window, where the first bot to fill it locks out\n * every visitor behind an untrusted proxy. The honeypot still applies, because\n * it reads the payload rather than the caller.\n */\nasync function judgeSubmission(\n payload: Record<string, unknown>,\n form: Record<string, unknown>,\n http: { ip: string | null; method: string },\n nextly: NextlyInstance\n): Promise<SpamCheckResult | undefined> {\n const config = getFormBuilderConfig(nextly);\n if (!config) return undefined;\n\n // The trap is set among the keys the form does NOT declare. Several honeypot\n // names are ones a real form might use -- `website`, `url_field` -- so\n // probing the whole payload would flag every submission to a form that\n // declares one, and flag it as bot traffic.\n const declared = new Set(\n (Array.isArray(form.fields) ? form.fields : [])\n .map(field => (field as { name?: unknown }).name)\n .filter((name): name is string => typeof name === \"string\")\n );\n const undeclared = Object.fromEntries(\n Object.entries(payload).filter(([key]) => !declared.has(key))\n );\n\n const settings = (form.settings ?? {}) as { honeypotEnabled?: boolean };\n const verdict = await checkSpam({\n data: undeclared,\n ipAddress: http.ip ?? undefined,\n formSlug: rateLimitKeyFor(form),\n config: {\n honeypot: settings.honeypotEnabled ?? config.spamProtection.honeypot,\n rateLimit: config.spamProtection.rateLimit,\n },\n });\n return verdict.isSpam ? verdict : undefined;\n}\n\n/**\n * Record a payload change this hook derived, since nothing else will.\n *\n * A patch that did not carry a payload but changed one is still an edit of what\n * the visitor sent: moving a submission to another form re-projects its answers\n * onto that form's fields and can drop one it does not declare. The stamp on\n * `beforeUpdate` has already run by then and only marks a patch that arrived\n * with `data`, so without this the stored answers change with nothing in the\n * row saying who changed them.\n */\nfunction stampDerivedEdit(\n row: Record<string, unknown>,\n user: { id?: string } | undefined,\n before: Record<string, unknown>,\n after: Record<string, unknown>\n): void {\n if (sameSubmittedPayload(before, after)) return;\n row.editedAt = new Date();\n row.editedBy = user?.id ?? null;\n}\n\nasync function handleSubmissionCreated(\n context: unknown,\n config: ResolvedFormBuilderConfig,\n nextly: NextlyInstance\n): Promise<void> {\n // The plugin-level kill switch: `notifications.enabled: false` turns off\n // all form emails regardless of per-rule state.\n if (!config.notifications.enabled) return;\n\n const submission = (context as { data?: Record<string, unknown> }).data;\n if (!submission) return;\n\n // Spam is stored for review, but nobody gets emailed about it — otherwise\n // every bot hit would trigger the form's notification rules.\n if (submission.status === \"spam\") return;\n\n const formId = parentFormId(submission);\n if (!formId) return;\n\n // Fetch the parent form\n const declaredFormsSlug = config.formOverrides.slug;\n const form = await fetchParentForm(\n nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug,\n formId,\n nextly\n );\n if (!form) return;\n\n const notifications = Array.isArray(form.notifications)\n ? (form.notifications as FormNotification[])\n : [];\n if (notifications.length === 0) return;\n\n const emailService = nextly.services.email;\n if (!emailService) return;\n\n // The afterCreate hook receives the raw DB row, so on dialects that store\n // JSON columns as text (e.g. SQLite) `data` arrives serialized. Without\n // parsing it, {{field}} recipients, reply-to references, and send\n // conditions all silently see an empty submission.\n const submittedData = parseJsonColumn(submission.data);\n\n // Collect mediaIds from file fields marked for email attachment\n const formFields = Array.isArray(form.fields)\n ? (form.fields as Array<Record<string, unknown>>)\n : [];\n const fileAttachments = collectAttachmentInputs(formFields, submittedData);\n\n // -- Build phase: resolve each enabled notification into an outgoing\n // descriptor (the value the D63 seam transforms).\n const { emails, skipped } = buildNotificationEmails({\n notifications,\n submittedData,\n formName: form.name,\n submissionId: submission.id,\n defaultFrom: config.notifications.defaultFrom,\n });\n\n for (const skip of skipped) {\n if (skip.reason === \"empty-recipient\") {\n nextly.logger.warn?.(\n \"Form Builder: empty recipient, skipping notification\",\n { notificationId: skip.notificationId, formSlug: form.slug }\n );\n } else {\n // Unmet conditions and missing templates are expected states, not\n // faults — surface them only at debug level.\n nextly.logger.debug?.(\"Form Builder: notification skipped\", {\n notificationId: skip.notificationId,\n formSlug: form.slug,\n reason: skip.reason,\n });\n }\n }\n\n if (emails.length === 0) return;\n\n // -- Seam: thread the outgoing notifications through the D63 filter so user\n // config (and any other registered handler) can modify/filter them.\n const finalEmails = await nextly.filters.apply<\n FormEmailNotification[],\n BeforeEmailFilterContext\n >(\"form-builder.beforeEmail\", emails, { form, submission });\n\n // -- Send phase: send the (possibly transformed) outgoing notifications.\n for (const email of finalEmails) {\n try {\n await emailService.sendWithTemplate(\n email.templateSlug,\n email.to,\n email.variables,\n {\n providerId: email.providerId,\n from: email.from,\n replyTo: email.replyTo,\n cc: email.cc,\n bcc: email.bcc,\n attachments: fileAttachments.length > 0 ? fileAttachments : undefined,\n }\n );\n\n nextly.logger.info?.(\"Form Builder: notification sent\", {\n formSlug: form.slug,\n to: email.to,\n templateSlug: email.templateSlug,\n notificationId: email.notificationId,\n attachmentCount: fileAttachments.length,\n });\n } catch (err) {\n nextly.logger.error?.(\"Form Builder: notification failed\", {\n to: email.to,\n templateSlug: email.templateSlug,\n notificationId: email.notificationId,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n}\n\n/**\n * Fetch the parent form document for a submission.\n */\n/**\n * Put a real `submissionCount` on every form a read returned.\n *\n * Spam is excluded, because the number answers \"how many people submitted\", not\n * \"how many bots\". `afterRead` fires for single reads and for list reads, so\n * both shapes are handled; the counts run concurrently, and form lists are\n * paginated, so this is a bounded fan-out of small indexed queries rather than\n * a serial walk.\n *\n * A read that asked for the schema alone is left alone. A submission write\n * reads its parent form only to check the payload against that form's fields,\n * and counting there is presentation work nobody on that path reads. It also\n * grows with the form's history, so every submission was paying for a count of\n * every submission before it.\n *\n * The flag decides how much work to do and nothing else. Forged, the worst it\n * can produce is a form read whose `submissionCount` is absent, and it cannot\n * come from a request body in any case: the hook context is set by the\n * server-side caller of the service.\n */\nexport async function injectSubmissionCount(\n context: unknown,\n submissionSlug: string,\n nextly: NextlyInstance\n): Promise<void> {\n const hook = context as {\n data?: unknown;\n context?: Record<string, unknown>;\n };\n if (hook.context?.[SCHEMA_ONLY_READ] === true) return;\n\n const records = Array.isArray(hook.data)\n ? hook.data\n : hook.data\n ? [hook.data]\n : [];\n await Promise.all(\n records.map(async record => {\n const form = record as Record<string, unknown>;\n if (typeof form.id !== \"string\") return;\n try {\n // Count as system: whoever may read the form may see its submission\n // volume without holding submission read rights.\n form.submissionCount = await nextly.services.collections.count(\n submissionSlug,\n {\n where: {\n form: { equals: form.id },\n status: { not_equals: \"spam\" },\n },\n },\n { as: \"system\" }\n );\n } catch {\n // A failed count must never break reading the form itself.\n form.submissionCount = 0;\n }\n })\n );\n}\n\n/**\n * Tells the form's `afterRead` hook that this read wants the schema and nothing\n * else, so it can skip work whose only purpose is to be displayed.\n */\nconst SCHEMA_ONLY_READ = \"formBuilder.schemaOnlyRead\";\n\nexport async function fetchParentForm(\n formsSlug: string,\n formId: string,\n nextly: NextlyInstance\n): Promise<Record<string, unknown> | null> {\n try {\n // D35/D56: read the parent form through the secure managed service as\n // system — the afterCreate hook runs without an ambient user. Replaces the\n // legacy `getCollectionsHandler()` + `overrideAccess` runtime path.\n //\n // The slug arrives RESOLVED. Passing `formOverrides.slug` read the declared\n // name, which a host that renames the collection has taken out of the\n // registry, so the lookup found nothing on exactly the installs the rename\n // API supports.\n //\n // NOT on the caller's transaction, and it cannot be here. The plugin facade\n // rebuilds its context from `user` and `overrideAccess` alone, and\n // `CollectionService.findEntryById` forwards only those two to the entry\n // service, so an executor has nowhere to go on this path: passing one\n // looked like transaction awareness and was discarded before the query.\n //\n // The cost is written down rather than hidden. A submission created inside\n // a transaction alongside a form created in that same transaction cannot\n // see it and is refused, and on a pool whose only connection the\n // transaction holds, this read waits for it. Both fail safely. The pattern\n // for fixing it exists in collection-hook-service and\n // collection-access-service, which forward a context's executor for exactly\n // this reason; bringing it here means changing the entry service and the\n // plugin facade with it.\n const form = await nextly.services.collections.findEntryById(\n formsSlug,\n formId,\n { as: \"system\", context: { [SCHEMA_ONLY_READ]: true } }\n );\n return form;\n } catch (err) {\n // A form that is not there is an answer, and the caller decides what a\n // submission naming no form means. Anything else is the read itself\n // failing, and it is rethrown: swallowing a pool timeout or a throwing\n // `afterRead` hook turned a server fault into \"this form could not be\n // read\", so the writer was told their submission was invalid, with a status\n // that says not to retry.\n if (!NextlyError.isNotFound(err)) throw err;\n nextly.logger.error?.(\"Form Builder: form not found\", {\n formId,\n error: err instanceof Error ? err.message : String(err),\n });\n return null;\n }\n}\n","/**\n * Forms Collection\n *\n * Stores form definitions created via Code-First or Schema Builder.\n * Each form contains field definitions, settings, notifications, and access control.\n *\n * @module collections/forms\n * @since 0.1.0\n */\n\nimport { NextlyError } from \"@nextlyhq/plugin-sdk\";\nimport type { CollectionConfig, FieldConfig, HookContext } from \"nextly\";\nimport {\n text,\n textarea,\n select,\n checkbox,\n date,\n group,\n json,\n relationship,\n} from \"nextly\";\n\nimport type { ResolvedFormBuilderConfig } from \"../types\";\nimport {\n documentReachability,\n parseRedirectReference,\n pickedDocumentField,\n type PickedDocumentField,\n} from \"../utils/redirect-reference\";\nimport {\n applyRedirectPattern,\n type RedirectRelationships,\n type RedirectTargetDocument,\n} from \"../utils/redirect-target\";\n\nimport { accessWithDefaults } from \"./access-defaults\";\n\n// ============================================================\n// Type Augmentation for Custom Admin Components\n// ============================================================\n// These types extend CollectionAdminOptions to support custom admin views.\n// This augmentation will be removed once nextly exports these types.\n\n/**\n * Component path string format.\n * Format: `\"package-name/path#ExportName\"`\n */\ntype ComponentPath = string;\n\n/**\n * Custom view configuration for replacing default admin views.\n */\ninterface CollectionAdminViewConfig {\n /** Component path to the custom view component */\n Component: ComponentPath;\n}\n\n/**\n * Custom components configuration for collection admin UI.\n */\ninterface CollectionAdminComponents {\n views?: {\n Edit?: CollectionAdminViewConfig;\n List?: CollectionAdminViewConfig;\n };\n BeforeListTable?: ComponentPath;\n AfterListTable?: ComponentPath;\n BeforeEdit?: ComponentPath;\n AfterEdit?: ComponentPath;\n}\n\n/**\n * Extended CollectionConfig with custom admin components support.\n * This extends the base CollectionConfig to include the components property.\n */\ninterface ExtendedCollectionConfig extends Omit<CollectionConfig, \"admin\"> {\n admin?: CollectionConfig[\"admin\"] & {\n /** Whether this collection is provided by a plugin */\n isPlugin?: boolean;\n components?: CollectionAdminComponents;\n };\n}\n\n/**\n * Generate the Forms collection configuration.\n *\n * Creates a collection for storing form definitions with fields,\n * settings, notifications, and access control.\n *\n * @param pluginConfig - Resolved plugin configuration\n * @returns ExtendedCollectionConfig for the Forms collection (with custom admin components)\n *\n * @example\n * ```typescript\n * const formsCol = formsCollection(resolvedConfig);\n * // Returns a collection with slug \"forms\" (or custom slug from config)\n * ```\n */\n/**\n * The plugin's hooks, with a host's own appended per phase.\n *\n * A host may pass `formOverrides.hooks`, and the trailing `...overrides` spread\n * would otherwise replace this object outright — taking with it the slug\n * generation, the at-least-one-field rule and the redirect check, none of\n * which are the host's to remove. Those are invariants of the collection this\n * plugin ships; an override extends it rather than disarming it.\n *\n * Handlers in `pluginHooks` run FIRST — they are transformations a host may\n * want to build on, like the generated slug. Handlers in `trailing` run LAST,\n * after every host handler in that phase, which is where an INVARIANT has to\n * sit: one placed first is checked against a payload the host can still\n * rewrite afterwards.\n */\nfunction withHostHooks<T>(\n pluginHooks: T,\n hostHooks: T | undefined,\n trailing: Partial<Record<string, unknown[]>> = {}\n): T {\n // Annotated rather than asserted. `hostHooks ?? {}` widens to `T | {}`\n // under the generic and cannot be indexed by phase name; a cast says the\n // same thing but reads as removable, and `--fix` removes it.\n const host: Record<string, unknown> = { ...hostHooks };\n\n const asList = (value: unknown) =>\n Array.isArray(value) ? value : value === undefined ? [] : [value];\n\n const merged: Record<string, unknown> = { ...host };\n for (const [phase, handlers] of Object.entries(\n pluginHooks as Record<string, unknown>\n )) {\n merged[phase] = [...asList(handlers), ...asList(host[phase])];\n }\n for (const [phase, handlers] of Object.entries(trailing)) {\n merged[phase] = [...asList(merged[phase]), ...asList(handlers)];\n }\n return merged as T;\n}\n\n/**\n * Refuses a write whose redirect target the resolver could not use.\n *\n * One function, called from two phases. `beforeValidate` so an author gets the\n * error where the rest of the form's errors appear, and LAST in `beforeChange`\n * so the guarantee survives a host hook: a check placed before host handlers\n * judges a payload they can still rewrite, and one that can be rewritten past\n * is not an invariant.\n *\n * `setsSettings` mirrors the fields rule above. An update carries the patch\n * rather than the merged document, so treating an absent `settings` as empty\n * would reject every partial update — renaming a form — for a setting it never\n * touched.\n */\n/**\n * The settings a write is SETTING to a page redirect, or null for every other\n * write.\n *\n * `setsSettings` mirrors the fields rule above. An update carries the patch\n * rather than the merged document, so treating an absent `settings` as empty\n * would refuse a rename for a setting it never touched.\n */\nfunction documentRedirectSettings(\n settings: unknown\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (typeof settings !== \"object\" || settings === null) return null;\n const record = settings as Record<string, unknown>;\n const field = pickedDocumentField(record);\n return field ? { settings: record, field } : null;\n}\n\nfunction pageRedirectSettings(\n data: Record<string, unknown> | undefined,\n operation: string\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (!data) return null;\n if (operation !== \"create\" && data.settings === undefined) return null;\n return documentRedirectSettings(data.settings);\n}\n\n/**\n * Whether the form this write leaves behind can receive a submission.\n *\n * A form's `status` is its own lifecycle — draft, published, closed — and only\n * a published form accepts submissions, so only a published form can send a\n * visitor anywhere at all. That is the whole reason this rule is conditional:\n * a draft form pointing at a draft page is fine, because the two go live\n * together.\n *\n * The stored status is consulted when the write does not carry one. Publishing\n * a form and editing its settings are separate saves, and an update that never\n * mentions `status` leaves the stored one standing — so reading an absent\n * field as \"not published\" would wave through exactly the case this rule\n * exists to catch.\n */\nexport function formAcceptsSubmissions(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined\n): boolean {\n return (data?.status ?? originalData?.status) === \"published\";\n}\n\n/**\n * The stored `settings`, which is not the shape the payload uses.\n *\n * Measured on 2026-08-25: the incoming write carries `settings` as an OBJECT,\n * and the same field on `originalData` comes back as JSON TEXT. Reading the\n * stored row with the payload's reader answers \"no redirect configured\" for\n * every form that has one — silently, and indistinguishably from a form that\n * genuinely has none.\n */\nfunction storedSettings(\n value: unknown\n): { settings: Record<string, unknown>; field: PickedDocumentField } | null {\n if (typeof value === \"string\") {\n try {\n return documentRedirectSettings(JSON.parse(value));\n } catch {\n return null;\n }\n }\n return documentRedirectSettings(value);\n}\n\n/**\n * The redirect target already stored on the form, for a write that does not\n * mention it.\n *\n * Publishing a form is such a write: it carries `status` and nothing else, and\n * the target it inherits is the one a visitor will be sent to. Without this,\n * the rule would guard the save that PICKS a draft page and ignore the save\n * that makes that same pairing reachable — protecting one path while appearing\n * to protect both.\n *\n * Nothing here is written back. `originalData` is the stored row, not the\n * payload; normalising it would edit a document this write never touched.\n */\nfunction storedRedirectReference(\n originalData: Record<string, unknown> | undefined,\n patterns: RedirectRelationships\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n /** Whether that collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const picked = storedSettings(originalData?.settings);\n if (!picked) return null;\n\n const collections = Object.keys(patterns);\n const reference = parseRedirectReference(\n picked.settings[picked.field],\n collections\n );\n if (!reference) return null;\n\n // A collection dropped from `redirectRelationships` since the form was saved\n // is REPORTED, not discarded. Discarding it made the rule skip a form whose\n // destination can no longer be built at all, so publishing succeeded and\n // every submission afterwards resolved to no redirect — the silent version\n // of the failure the rule exists to prevent.\n return {\n reference,\n field: picked.field,\n configured: collections.includes(reference.collection),\n };\n}\n\n/**\n * Writes the parser's normalised names back into the row being saved.\n *\n * Not merely read: trimming only the parsed copy leaves the padded name in\n * `data`, so this rule accepts the write and the framework's own relationship\n * validator downstream still sees `\" pages \"`. The plugin and the framework\n * then disagree about a value this parser has already decided.\n */\nfunction normalizeStoredReference(\n settings: Record<string, unknown>,\n field: PickedDocumentField,\n reference: { collection: string; id: string }\n) {\n const stored = settings[field];\n\n if (typeof stored === \"string\") {\n if (stored !== reference.id) settings[field] = reference.id;\n return;\n }\n if (typeof stored !== \"object\" || stored === null || Array.isArray(stored)) {\n return;\n }\n\n const record = stored as Record<string, unknown>;\n if (record.relationTo !== reference.collection) {\n settings[field] = { ...record, relationTo: reference.collection };\n }\n}\n\nfunction namedRedirectReference(\n data: Record<string, unknown> | undefined,\n operation: string,\n patterns: RedirectRelationships\n): {\n settings: Record<string, unknown>;\n field: PickedDocumentField;\n /** Null when the settings name a redirect this cannot read. */\n reference: { collection: string; id: string } | null;\n /** Whether that collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const picked = pageRedirectSettings(data, operation);\n if (!picked) return null;\n const { settings, field } = picked;\n\n const collections = Object.keys(patterns);\n const reference = parseRedirectReference(settings[field], collections);\n if (reference) normalizeStoredReference(settings, field, reference);\n\n return {\n settings,\n field,\n reference,\n configured:\n reference !== null && collections.includes(reference.collection),\n };\n}\n\n/**\n * The same rule, plus the one question that needs a read: can this document\n * actually fill its collection's pattern?\n *\n * Split from the shape check so the cheap half stays synchronous — the early\n * `beforeValidate` call wants an error, not a database round trip, and making\n * that hook async changes what a caller has to do to observe its rejection.\n *\n * Best effort by design: `req.nextly` is optional, and a slug can be emptied\n * AFTER this form is saved. The submit path keeps its own check and its log\n * line. What this buys is telling the author NOW, while the page they picked\n * is in front of them, rather than leaving a form that saves cleanly and\n * redirects nobody.\n */\n/**\n * Whether this write leaves a form that would send visitors to a page they\n * cannot reach.\n *\n * One question, so it has one implementation and one name. It is the whole\n * rule: a form that accepts submissions is the only kind that redirects\n * anyone, and an unreachable target is the only thing that 404s them. A draft\n * form pointing at a draft page satisfies neither half and is allowed — the\n * picker offers unpublished pages on purpose, because a form is usually\n * configured beside the page it points at and the two go live together.\n */\nexport function wouldStrandVisitors(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined,\n target: RedirectTargetDocument,\n targetCollectionIsLocalized: boolean | undefined\n): boolean {\n return (\n formAcceptsSubmissions(data, originalData) &&\n documentReachability(target, targetCollectionIsLocalized) === \"unreachable\"\n );\n}\n\n/**\n * Whether this write is the one that makes the form able to receive\n * submissions.\n *\n * Distinct from {@link formAcceptsSubmissions}, which answers what the form\n * WILL be: this asks what the write CHANGES, which is what decides whether an\n * inherited target is any of its business. A form that was already published\n * is not made reachable again by an edit that merely restates its status.\n */\nfunction publishesForm(\n data: Record<string, unknown> | undefined,\n originalData: Record<string, unknown> | undefined\n): boolean {\n if (data?.status !== \"published\") return false;\n // Already published before this write, so this write is not what makes the\n // form reachable. The admin sends `status` on every save exactly as it sends\n // `settings`, so reading the value alone treats every edit to a live form as\n // the moment it went live.\n return originalData?.status !== \"published\";\n}\n\n/**\n * The page this write leaves the form pointing at, and whether the write\n * CHOSE it.\n *\n * The distinction decides which refusals apply. A write that names a page\n * answers for that page; a write that only publishes the form, or renames it,\n * inherits whatever is stored and must not be refused for it.\n */\n/**\n * Whether two readings name the same document in the same field.\n *\n * `null` on either side is not a match: a reference this cannot read has not\n * been shown to be the stored one.\n */\nfunction sameTarget(\n named: {\n field: PickedDocumentField;\n reference: { collection: string; id: string } | null;\n },\n stored: {\n field: PickedDocumentField;\n reference: { collection: string; id: string };\n } | null\n): boolean {\n if (!named.reference || !stored) return false;\n return (\n stored.field === named.field &&\n stored.reference.collection === named.reference.collection &&\n stored.reference.id === named.reference.id\n );\n}\n\n/**\n * What a write that NAMES a target is answering for.\n *\n * NAMING is not CHOOSING, and the refusal has to come after that distinction\n * rather than before it. The admin's save sends the whole `settings` object on\n * every write, so a rename carries the redirect it never touched — judging the\n * mention meant a form whose collection had since been unconfigured could not\n * be edited at all.\n */\nfunction judgeNamedReference(\n named: {\n field: PickedDocumentField;\n reference: { collection: string; id: string } | null;\n configured: boolean;\n },\n stored: {\n field: PickedDocumentField;\n reference: { collection: string; id: string };\n } | null\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n chosen: boolean;\n configured: boolean;\n} {\n if (sameTarget(named, stored) && named.reference) {\n return {\n reference: named.reference,\n field: named.field,\n chosen: false,\n configured: named.configured,\n };\n }\n\n // This write moved the target, so it answers for what it moved it to.\n if (!named.reference || !named.configured) {\n throw redirectRefusal(\n \"Choose a page to redirect to, or pick a different confirmation.\",\n named.field\n );\n }\n return {\n reference: named.reference,\n field: named.field,\n chosen: true,\n configured: true,\n };\n}\n\nfunction redirectReferenceForWrite(\n context: HookContext,\n patterns: RedirectRelationships\n): {\n reference: { collection: string; id: string };\n field: PickedDocumentField;\n chosen: boolean;\n /** Whether the target's collection is still a configured redirect target. */\n configured: boolean;\n} | null {\n const stored = storedRedirectReference(\n context.originalData as Record<string, unknown> | undefined,\n patterns\n );\n const named = namedRedirectReference(\n context.data,\n context.operation,\n patterns\n );\n\n if (named) return judgeNamedReference(named, stored);\n\n // A write that REPLACES `settings` has answered the question: this form no\n // longer redirects to a page. Inheriting the old target there would refuse\n // the very edit that removes an invalid redirect, which is the one edit an\n // author in that state needs to make. Only a write that omits `settings`\n // inherits.\n const data = context.data as Record<string, unknown> | undefined;\n if (data?.settings !== undefined) return null;\n\n return stored ? { ...stored, chosen: false } : null;\n}\n\n/**\n * Whether the collection's configured pattern can build a URL for this\n * document.\n *\n * A pattern may be a FUNCTION, which is host code and can throw. Authoring\n * must not be blocked by that: the submission path contains the same throw and\n * degrades to no redirect with a log line, so a broken pattern costs a\n * destination rather than the ability to edit forms.\n */\nfunction assertPatternBuildsUrl(\n patterns: RedirectRelationships,\n reference: { collection: string; id: string },\n field: PickedDocumentField,\n target: RedirectTargetDocument\n) {\n let url: string | undefined;\n try {\n url = applyRedirectPattern(patterns[reference.collection], target);\n } catch {\n return;\n }\n if (url) return;\n\n // Pattern-agnostic. The configured pattern may depend on any field, or be a\n // function that declines for its own reasons, so naming a slug states a\n // remedy that often cannot fix the actual failure — and the save is\n // correctly blocked either way, leaving the author following wrong advice.\n throw redirectRefusal(\n `No URL could be built for that page from the pattern configured for ` +\n `\"${reference.collection}\". Choose another page, or ask a developer ` +\n `to check that collection's redirect pattern.`,\n field\n );\n}\n\nexport async function assertRedirectTargetUsable(\n context: HookContext,\n patterns: RedirectRelationships,\n localization: Record<string, boolean>\n) {\n const write = redirectReferenceForWrite(context, patterns);\n if (!write) return;\n const { reference, field, chosen, configured } = write;\n\n // Nothing can be built for a collection that is no longer a redirect target,\n // so there is nothing to read either. Only the write that makes the form\n // reachable answers for it; an unrelated edit inherits the state as it does\n // any other.\n if (!configured) {\n if (\n publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n )\n ) {\n throw redirectRefusal(\n `This form redirects to \"${reference.collection}\", which is no longer ` +\n `configured as a redirect target, so it has nowhere to send anyone. ` +\n `Choose another page, or pick a different confirmation.`,\n field\n );\n }\n return;\n }\n\n const nextly = context.req?.nextly;\n if (!nextly) return;\n\n // Not inside a caller-owned transaction. `context.executor` is present only\n // there, and the contract on it is explicit: a hook that reads the database\n // must use that executor, because a second pooled connection can stall\n // against a small pool while the caller's transaction holds the only one —\n // and a pooled read cannot see the transaction's own uncommitted rows, so a\n // page created in the same transaction would read as missing and this would\n // refuse a correct write.\n //\n // `findByID` takes no executor, and reaching for Drizzle directly here would\n // be raw database access inside a plugin. So the transactional path keeps\n // the shape check and skips the read; the submit path is the backstop it\n // already was.\n if (context.executor) return;\n\n const found = await readRedirectTarget(nextly, reference);\n if (found.status === \"unreadable\") return;\n if (found.status === \"missing\") {\n // A write that neither NAMES the page nor makes the form able to send\n // anyone there does not answer for it: a rename inherits whatever is\n // stored, and refusing it would block an edit for a setting it never\n // touched.\n //\n // Publishing is not that write. It is the moment the pairing starts\n // reaching visitors, and a deleted target is a stricter case than the\n // unpublished one refused below — every submission resolves it to no\n // redirect at all. Returning here on `!chosen` alone let the worse case\n // through while the milder one was refused.\n if (\n !chosen &&\n !publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n )\n ) {\n return;\n }\n throw redirectRefusal(\n `That page no longer exists in \"${reference.collection}\". Pick another.`,\n field\n );\n }\n\n // An inherited target is only judged by the write that PUBLISHES the form.\n // A published form can acquire a draft target without being touched — the\n // page is unpublished later, or the pairing predates this rule — and\n // refusing every subsequent rename would hold the form hostage to a state\n // the write neither created nor mentions. A write that CHOOSES a target\n // answers for it whatever else it does.\n const judged =\n chosen ||\n publishesForm(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined\n );\n\n if (\n judged &&\n wouldStrandVisitors(\n context.data as Record<string, unknown> | undefined,\n context.originalData as Record<string, unknown> | undefined,\n found.document,\n localization[reference.collection]\n )\n ) {\n throw redirectRefusal(\n `That page is not published yet, and this form is. A visitor sent ` +\n `there would reach a \"page not found\". Publish the page, or save ` +\n `this form as a draft until you do.`,\n field\n );\n }\n\n // The pattern belongs to the write that CHOOSES a page. A form being\n // published does not get refused for a pattern its author never edited.\n if (chosen)\n assertPatternBuildsUrl(patterns, reference, field, found.document);\n}\n\n/**\n * The target document, as one of three outcomes.\n *\n * Three rather than a nullable document, because \"the page is not there\" and\n * \"I could not ask\" are different answers and only the first is the author's\n * to fix. Collapsing them has been wrong in both directions on this rule:\n * refusing on any failure blocks a save a retry would complete, and skipping\n * on any failure loses the deleted-page refusal entirely.\n */\nasync function readRedirectTarget(\n nextly: NonNullable<NonNullable<HookContext[\"req\"]>[\"nextly\"]>,\n reference: { collection: string; id: string }\n): Promise<\n | { status: \"ok\"; document: RedirectTargetDocument }\n | { status: \"missing\" }\n | { status: \"unreadable\" }\n> {\n try {\n const row = (await nextly.findByID({\n collection: reference.collection,\n id: reference.id,\n })) as RedirectTargetDocument | null;\n return row ? { status: \"ok\", document: row } : { status: \"missing\" };\n } catch (error) {\n return isMissingTarget(error)\n ? { status: \"missing\" }\n : { status: \"unreadable\" };\n }\n}\n\n/**\n * Whether a failed read means the document is GONE, or that it could not be\n * asked for.\n *\n * Both arrive as exceptions — `findByID` throws `NOT_FOUND` for a missing\n * entry rather than returning null, despite a signature that says `| null` —\n * so the two are separated by the error's own code. Not by its message, which\n * is prose, and not by catching everything, which has now been wrong in both\n * directions: refusing on any failure blocks a save a retry would complete,\n * and skipping on any failure loses the deleted-page refusal.\n */\nexport function isMissingTarget(error: unknown): boolean {\n return (\n typeof error === \"object\" &&\n error !== null &&\n (error as { code?: unknown }).code === \"NOT_FOUND\"\n );\n}\n\n/**\n * Records the moment a form first goes live.\n *\n * `status` on this collection is an ordinary select, not the framework's\n * publish lifecycle, so nothing else writes down that a form was ever public.\n * Without that, `closed` stands for two different histories — a form an author\n * released and later stopped, and one created closed that nobody ever saw —\n * and the public by-slug endpoint served both.\n *\n * Written once. A form republished after being closed keeps the original date,\n * because the question this answers is \"has this ever been public\", not \"when\n * was it last\".\n */\nfunction stampFirstPublish(context: HookContext): void {\n const data = context.data as Record<string, unknown> | undefined;\n if (!data) return;\n const stored = context.originalData as Record<string, unknown> | undefined;\n\n // Written here or not at all. Duplicating an entry copies every non-system\n // field, so a copy of a closed form arrives carrying the original's stamp\n // and would qualify as previously public at a slug nobody has ever seen. The\n // same goes for any caller that simply sends the field. Removing it from the\n // patch leaves a stamp that IS stored untouched, because a column absent\n // from an update is a column not written.\n delete data.wentLiveAt;\n\n if (recordedStamp(stored) !== undefined) return;\n\n // Either side of the transition is proof. A form live before this field\n // existed carries no stamp, so its first edit is the only chance to record\n // one — and if that edit is the one CLOSING it, reading the incoming status\n // alone would miss it and take a form that had been public offline.\n if (data.status === \"published\" || stored?.status === \"published\") {\n data.wentLiveAt = new Date();\n }\n}\n\n/** The stamp a form already carries, treating blank as absent. */\nfunction recordedStamp(stored: Record<string, unknown> | undefined): unknown {\n const value = stored?.wentLiveAt;\n return value === null || value === \"\" ? undefined : value;\n}\n\n/** One shape for every refusal this rule makes, so callers see one field. */\nfunction redirectRefusal(message: string, field: PickedDocumentField) {\n // The path names the field the author actually filled in. Reporting every\n // refusal against `redirectPage` points a form configured through the URL\n // option at a control it does not show.\n return NextlyError.validation({\n errors: [{ path: `settings.${field}`, code: \"REQUIRED\", message }],\n });\n}\n\nexport function formsCollection(\n pluginConfig: ResolvedFormBuilderConfig\n): ExtendedCollectionConfig {\n const {\n slug,\n labels,\n fields: additionalFields,\n access: accessOverrides,\n hooks: hookOverrides,\n admin: adminOverrides,\n ...overrides\n } = pluginConfig.formOverrides;\n\n // Derived from the map rather than carried beside it: the collections a form\n // may redirect to are exactly the ones with a URL pattern configured, so\n // there is no second list to fall out of step with the first.\n const redirectCollections = Object.keys(pluginConfig.redirectRelationships);\n\n // Build settings group fields based on plugin configuration\n const settingsFields: FieldConfig[] = [\n text({\n name: \"submitButtonText\",\n label: \"Submit Button Text\",\n defaultValue: \"Submit\",\n admin: {\n description: \"Text displayed on the form submit button\",\n },\n }),\n\n select({\n name: \"confirmationType\",\n label: \"After Submission\",\n defaultValue: \"message\",\n options: [\n { label: \"Show Message\", value: \"message\" },\n { label: \"Redirect to URL\", value: \"redirect\" },\n ...(redirectCollections.length > 0\n ? [{ label: \"Redirect to Page\", value: \"relationship\" }]\n : []),\n ],\n admin: {\n description: \"What happens after a successful form submission\",\n },\n }),\n\n textarea({\n name: \"successMessage\",\n label: \"Success Message\",\n defaultValue: \"Thank you for your submission!\",\n admin: {\n description: \"Message shown after successful submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"message\",\n },\n },\n }),\n\n text({\n name: \"redirectUrl\",\n label: \"Redirect URL\",\n admin: {\n description: \"URL to redirect to after submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"redirect\",\n },\n },\n }),\n ];\n\n // Add redirect relationship field if configured\n if (redirectCollections.length > 0) {\n settingsFields.push(\n relationship({\n name: \"redirectPage\",\n label: \"Redirect Page\",\n relationTo: redirectCollections,\n admin: {\n description: \"Select a page to redirect to after submission\",\n condition: {\n field: \"settings.confirmationType\",\n equals: \"relationship\",\n },\n },\n })\n );\n }\n\n // Add remaining settings fields\n settingsFields.push(\n checkbox({\n name: \"allowMultipleSubmissions\",\n label: \"Allow Multiple Submissions\",\n defaultValue: true,\n admin: {\n description:\n \"Allow the same user/IP to submit this form multiple times\",\n },\n }),\n\n checkbox({\n name: \"honeypotEnabled\",\n label: \"Honeypot\",\n admin: {\n description:\n \"Per-form honeypot override; unset inherits the plugin default\",\n },\n }),\n\n checkbox({\n name: \"captchaEnabled\",\n label: \"Enable reCAPTCHA\",\n admin: {\n description:\n \"Per-form reCAPTCHA override; unset inherits the plugin default\",\n },\n }),\n\n text({\n name: \"captchaSiteKey\",\n label: \"reCAPTCHA Site Key\",\n admin: {\n description: \"Override the default reCAPTCHA site key for this form\",\n condition: {\n field: \"settings.captchaEnabled\",\n equals: true,\n },\n },\n })\n );\n\n // Build the default fields array\n const defaultFields: FieldConfig[] = [\n // ============================================================\n // Basic Information\n // ============================================================\n text({\n name: \"name\",\n label: \"Form Name\",\n required: true,\n admin: {\n description: \"Internal name for this form (not shown to users)\",\n },\n }),\n\n text({\n name: \"slug\",\n label: \"Slug\",\n required: true,\n unique: true,\n admin: {\n description: \"URL-friendly identifier. Used in API: /api/forms/{slug}\",\n },\n }),\n\n textarea({\n name: \"description\",\n label: \"Description\",\n admin: {\n description: \"Internal notes about this form's purpose\",\n },\n }),\n\n // ============================================================\n // Form Fields (JSON schema managed by Form Builder UI)\n // ============================================================\n json({\n name: \"fields\",\n label: \"Form Fields\",\n required: true,\n defaultValue: [],\n admin: {\n description:\n \"Form field configuration. Managed by the Form Builder UI.\",\n },\n }),\n\n // ============================================================\n // Form Settings\n // ============================================================\n group({\n name: \"settings\",\n label: \"Form Settings\",\n fields: settingsFields,\n }),\n\n // ============================================================\n // Email Notifications\n // ============================================================\n json({\n name: \"notifications\",\n label: \"Notifications\",\n defaultValue: [],\n admin: {\n description:\n \"Email notification rules. Managed by the Form Builder UI.\",\n },\n }),\n\n // ============================================================\n // Status\n // ============================================================\n select({\n name: \"status\",\n label: \"Status\",\n required: true,\n defaultValue: \"draft\",\n options: [\n { label: \"Draft\", value: \"draft\" },\n { label: \"Published\", value: \"published\" },\n { label: \"Closed\", value: \"closed\" },\n ],\n admin: {\n description:\n \"Only published forms can receive submissions. Closed forms display a message instead.\",\n },\n }),\n\n // NOT `firstPublishedAt`, which is the obvious name and the one to reach\n // for next. The framework owns that name on collections it manages the\n // publish lifecycle for, and a write naming it from a plugin collection is\n // stripped on the way to the insert — silently, leaving the column null and\n // every closed form reading as one that was never public.\n date({\n name: \"wentLiveAt\",\n label: \"First Published\",\n admin: {\n readOnly: true,\n description:\n \"When this form first went live. Written once and never changed.\",\n },\n }),\n\n textarea({\n name: \"closedMessage\",\n label: \"Closed Form Message\",\n defaultValue: \"This form is no longer accepting submissions.\",\n admin: {\n description: \"Message shown when form status is 'closed'\",\n condition: {\n field: \"status\",\n equals: \"closed\",\n },\n },\n }),\n ];\n\n // Process additional fields from formOverrides\n // Note: Function-style overrides should be resolved in plugin.ts before reaching here\n let finalFields: FieldConfig[];\n\n if (Array.isArray(additionalFields) && additionalFields.length > 0) {\n // Merge: append additional fields to defaults\n finalFields = [...defaultFields, ...additionalFields];\n } else {\n finalFields = defaultFields;\n }\n\n // Convert hyphenated slug to underscored table name for database compatibility\n const dbName = slug.replace(/-/g, \"_\");\n\n return {\n slug,\n dbName,\n labels,\n fields: finalFields,\n timestamps: true,\n\n admin: {\n isPlugin: true,\n group: \"Forms\",\n order: 50,\n useAsTitle: \"name\",\n description:\n \"Create and manage your form templates using the visual form builder. Create and manage forms for collecting user submissions\",\n\n // Custom components for visual form builder\n components: {\n views: {\n // Replace default edit view with visual Form Builder\n Edit: {\n Component: \"@nextlyhq/plugin-form-builder/admin#FormBuilderView\",\n },\n },\n },\n\n ...adminOverrides,\n },\n\n // Access control with sensible defaults\n // `read: true` — a form is public because a site renders it. The rest are\n // the shared defaults.\n access: accessWithDefaults(accessOverrides, { read: true }),\n\n hooks: withHostHooks(\n {\n // Auto-generate slug from name if not provided\n beforeValidate: [\n (context: HookContext) => {\n const { data, operation } = context;\n\n // Auto-generate slug from name if not provided (only on create)\n if (operation === \"create\" && data && !data.slug && data.name) {\n data.slug = String(data.name)\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n }\n\n // A form must have at least one field, checked against what the\n // write actually sets. An update carries the patch rather than the\n // merged document, so treating an absent `fields` as empty rejects\n // every partial update -- renaming a form, or changing a setting --\n // even though its fields are untouched and still there.\n const setsFields =\n operation === \"create\" || data?.fields !== undefined;\n if (\n data &&\n setsFields &&\n (data.fields === undefined ||\n (Array.isArray(data.fields) && data.fields.length === 0))\n ) {\n // Typed, so the rejection survives as a validation failure with\n // its field issue rather than being read as a crash and replaced\n // with a generic server-fault message.\n throw NextlyError.validation({\n errors: [\n {\n path: \"fields\",\n code: \"REQUIRED\",\n message: \"Form must have at least one field.\",\n },\n ],\n });\n }\n\n // Reported here so an author sees it beside the form's other\n // errors. The GUARANTEE is the trailing `beforeChange` call below,\n // which a host hook cannot run after.\n // Shape only at this phase: the same reading, without the\n // stored document a refusal for an unconfigured collection would\n // need. `assertRedirectTargetUsable` below makes that call.\n namedRedirectReference(\n data,\n operation,\n pluginConfig.redirectRelationships\n );\n\n return data;\n },\n ],\n },\n hookOverrides,\n {\n // The last COLLECTION-level phase, after every host handler in it. A\n // host `beforeValidate` may rewrite the payload once the earlier call\n // has passed, so the check has to run again here.\n //\n // It is not the last mutating point in the write, and saying so would\n // be false: `collection-mutation-service` runs FIELD-level\n // `beforeChange` hooks after this one, and a host may contribute a\n // field through `formOverrides.fields`. Nothing collection-level can\n // sit after that — the next steps are hashing and the insert. What\n // bounds the consequence is the submit path, which resolves an\n // unusable target to NO redirect and logs it, never to a wrong one.\n beforeChange: [\n async (context: HookContext) => {\n await assertRedirectTargetUsable(\n context,\n pluginConfig.redirectRelationships,\n pluginConfig.redirectTargetLocalization\n );\n stampFirstPublish(context);\n return context.data;\n },\n ],\n }\n ),\n\n // Spread any additional overrides (excluding already used properties)\n ...overrides,\n };\n}\n","/**\n * Where a form sends the visitor after a successful submission, when the\n * author picked a PAGE rather than typing a URL.\n *\n * The plugin cannot derive this. A site's URL structure belongs to the site:\n * the playground serves the `pages` collection at `/{slug}` from its own\n * catch-all route, and another app may serve the same collection under\n * `/blog/{slug}` or behind a locale segment. Nothing inside a CMS package can\n * read that decision off the collection, so the mapping is configuration.\n *\n * A pattern covers the common case and a function covers the rest — a path\n * that depends on a parent document, a locale, or anything else the site\n * knows and this package does not.\n *\n * The pattern syntax is not invented here. `previewUrlFromTemplate` already\n * turns a `{field}` path into a URL for preview links, for the same reason and\n * with the same constraint, so this uses it rather than substituting\n * alongside it: two rules that agree today would drift, and a wrong URL still\n * looks like a URL. Encoding and the treatment of `0` and `false` come with\n * it.\n */\nimport { previewUrlFromTemplate } from \"nextly/config\";\n\nimport type { RedirectTargetDocument } from \"./redirect-reference\";\n\nexport type { RedirectTargetDocument } from \"./redirect-reference\";\nexport {\n documentReachability,\n type Reachability,\n hasPublishLifecycle,\n parseRedirectReference,\n pickedDocumentField,\n type PickedDocumentField,\n type RedirectReference,\n} from \"./redirect-reference\";\n\n/**\n * How documents in one collection become URLs.\n *\n * A string is a path with `{field}` placeholders filled from the document.\n * A function receives the document and returns the path itself.\n */\nexport type RedirectUrlPattern =\n | string\n | ((document: RedirectTargetDocument) => string | undefined);\n\n/** The collection-to-pattern map, after the array shorthand is normalised. */\nexport type RedirectRelationships = Record<string, RedirectUrlPattern>;\n\n/**\n * The pattern assumed for the `string[]` shorthand.\n *\n * `redirectRelationships: [\"pages\"]` means \"pages are redirect targets\" and\n * says nothing about their URLs, so it needs a default. This one matches the\n * routing the page builder ships and the scaffold templates generate.\n */\nexport const DEFAULT_REDIRECT_PATTERN = \"/{slug}\";\n\n/**\n * Both accepted spellings of the option reduced to the map.\n *\n * Normalised at the edge so everything downstream reads ONE shape — including\n * the list of collections, which is `Object.keys` of this rather than a second\n * field that agrees with it today.\n */\nexport function normalizeRedirectRelationships(\n option: string[] | RedirectRelationships | undefined\n): RedirectRelationships {\n if (!option) return {};\n const entries = Array.isArray(option)\n ? option.map(collection => [collection, DEFAULT_REDIRECT_PATTERN] as const)\n : Object.entries(option);\n\n // An empty pattern is dropped rather than kept, so that being OFFERED as a\n // redirect target and being able to PRODUCE a URL are one decision. Kept,\n // the collection appears in the picker, validation accepts a reference into\n // it, and every submission then resolves to nothing — a form that passes\n // every check and sends nobody anywhere.\n return Object.fromEntries(\n entries.filter(\n ([, pattern]) => typeof pattern !== \"string\" || pattern.trim()\n )\n );\n}\n\n/**\n * The pattern applied to a document, or undefined when it cannot be.\n *\n * Undefined rather than a partial string: a placeholder whose field is empty\n * would otherwise produce `/undefined` or `/`, and a form that sends every\n * visitor to a broken URL is worse than one that sends them nowhere. The\n * caller reports the difference.\n */\nexport function applyRedirectPattern(\n pattern: RedirectUrlPattern,\n document: RedirectTargetDocument\n): string | undefined {\n if (typeof pattern === \"function\") {\n const produced = pattern(document);\n return produced ? produced : undefined;\n }\n return previewUrlFromTemplate(pattern)(document) ?? undefined;\n}\n","/**\n * The access rules both collections this plugin contributes fall back to.\n *\n * Written out per collection they were byte-identical apart from `read`, and\n * two copies of an access policy is the copy that matters most: they agree\n * today, one gets tightened later, and the other silently keeps the old rule\n * while looking deliberate.\n *\n * `read` and `create` are the axes they genuinely differ on, so both are\n * parameters: a form is publicly READABLE because a site renders it, and a\n * submission is publicly CREATABLE because a visitor makes it. Update and\n * delete are the same policy in both collections.\n *\n * @module collections/access-defaults\n */\n\nimport type { CollectionAccessControl } from \"nextly\";\n\n/** A single rule, in either form the collection config accepts. */\ntype AccessRule = NonNullable<CollectionAccessControl[\"read\"]>;\n\n/** The subset of a collection's access declaration these defaults supply. */\nexport type AccessOverrides = Pick<\n CollectionAccessControl,\n \"read\" | \"create\" | \"update\" | \"delete\"\n>;\n\n/**\n * The caller holds the permission for this operation on this collection, as\n * the role screen grants it: `read-form-submissions` to read submissions,\n * `update-forms` to edit a form.\n *\n * A collection's rule is the whole answer for a signed-in user, so \"signed in\"\n * here would admit every staff member whatever their role holds, and a role\n * name would refuse a custom role that holds the permission. The rule is given\n * the caller's permissions as `resource:action`, for a session and an API key\n * alike, and a visitor holds none. `collection` is the slug the host installed\n * the collection under, so a renamed collection is judged by its own\n * permissions. A super-admin is admitted before any rule runs.\n */\nconst permitted: AccessRule = ({ permissions, collection, operation }) =>\n permissions.includes(`${collection}:${operation}`);\n\n/**\n * Host overrides applied over this plugin's defaults, per operation.\n *\n * Each override is taken with `??`, so a host may pass `false` to close an\n * operation it would otherwise be given — which `||` would have silently\n * replaced with the default.\n */\nexport function accessWithDefaults(\n overrides: AccessOverrides | undefined,\n defaults: { read?: AccessRule; create?: AccessRule } = {}\n): AccessOverrides {\n return {\n read: overrides?.read ?? defaults.read ?? permitted,\n create: overrides?.create ?? defaults.create ?? permitted,\n update: overrides?.update ?? permitted,\n delete: overrides?.delete ?? permitted,\n };\n}\n","/**\n * Form Submissions Collection\n *\n * Stores form submission data from user-submitted forms.\n * Each submission contains the submitted data, metadata, and status.\n *\n * @module collections/submissions\n * @since 0.1.0\n */\n\nimport type { CollectionConfig, FieldConfig, HookContext } from \"nextly\";\nimport { text, textarea, select, json, date, relationship } from \"nextly\";\n\nimport type { ResolvedFormBuilderConfig } from \"../types\";\n\nimport { accessWithDefaults } from \"./access-defaults\";\n\n/**\n * Extended CollectionConfig with isPlugin support.\n * This extends the base CollectionConfig to include the isPlugin property.\n */\ninterface ExtendedCollectionConfig extends Omit<CollectionConfig, \"admin\"> {\n admin?: CollectionConfig[\"admin\"] & {\n /** Whether this collection is provided by a plugin */\n isPlugin?: boolean;\n };\n}\n\n/**\n * Generate the Form Submissions collection configuration.\n *\n * Creates a collection for storing form submissions with:\n * - Reference to the parent form\n * - Submitted data (JSON)\n * - Status tracking (new, read, archived)\n * - Metadata (IP, user agent, timestamp)\n * - Internal notes for admin use\n *\n * @param pluginConfig - Resolved plugin configuration\n * @returns CollectionConfig for the Submissions collection\n *\n * @example\n * ```typescript\n * const submissionsCol = submissionsCollection(resolvedConfig);\n * // Returns a collection with slug \"form-submissions\" (or custom slug from config)\n * ```\n */\nexport function submissionsCollection(\n pluginConfig: ResolvedFormBuilderConfig\n): ExtendedCollectionConfig {\n const {\n slug,\n labels,\n fields: additionalFields,\n access: accessOverrides,\n ...overrides\n } = pluginConfig.formSubmissionOverrides;\n\n // Get the forms collection slug for the relationship\n const formSlug = pluginConfig.formOverrides.slug;\n\n // Build the default fields array\n const defaultFields: FieldConfig[] = [\n // ============================================================\n // Form Relationship\n // ============================================================\n relationship({\n name: \"form\",\n label: \"Form\",\n relationTo: formSlug,\n required: true,\n admin: {\n readOnly: true,\n description: \"The form this submission belongs to\",\n },\n }),\n\n // ============================================================\n // Submission Data (JSON)\n // ============================================================\n json({\n name: \"data\",\n label: \"Submission Data\",\n required: true,\n admin: {\n readOnly: true,\n description: \"The submitted form data as JSON\",\n },\n }),\n\n // ============================================================\n // Status\n // ============================================================\n select({\n name: \"status\",\n label: \"Status\",\n required: true,\n defaultValue: \"new\",\n options: [\n { label: \"New\", value: \"new\" },\n { label: \"Read\", value: \"read\" },\n { label: \"Archived\", value: \"archived\" },\n // Spam is stored and flagged, never silently dropped: a false\n // positive stays reviewable and recoverable (\"Not spam\" = back to new).\n { label: \"Spam\", value: \"spam\" },\n ],\n admin: {\n description: \"Track whether this submission has been reviewed\",\n },\n }),\n\n text({\n name: \"spamReason\",\n label: \"Spam Reason\",\n admin: {\n readOnly: true,\n description:\n \"Which spam check flagged this submission (empty for clean submissions)\",\n },\n }),\n\n // ============================================================\n // Internal Notes\n // ============================================================\n textarea({\n name: \"notes\",\n label: \"Internal Notes\",\n admin: {\n description: \"Notes visible only to admins (not shown to submitter)\",\n },\n }),\n\n // ============================================================\n // Metadata\n // ============================================================\n text({\n name: \"ipAddress\",\n label: \"IP Address\",\n admin: {\n readOnly: true,\n description: \"IP address of the submitter\",\n },\n }),\n\n text({\n name: \"userAgent\",\n label: \"User Agent\",\n admin: {\n readOnly: true,\n description: \"Browser/device information of the submitter\",\n },\n }),\n\n date({\n name: \"submittedAt\",\n label: \"Submitted At\",\n required: true,\n admin: {\n readOnly: true,\n description: \"When the form was submitted\",\n },\n }),\n\n // Honesty stamps for admin edits of submitted data: a corrected\n // submission must never be indistinguishable from what the visitor sent.\n date({\n name: \"editedAt\",\n label: \"Edited At\",\n admin: {\n readOnly: true,\n description: \"When an admin last edited the submitted data\",\n },\n }),\n\n text({\n name: \"editedBy\",\n label: \"Edited By\",\n admin: {\n readOnly: true,\n description: \"ID of the admin who last edited the submitted data\",\n },\n }),\n ];\n\n // Process additional fields from formSubmissionOverrides\n // Note: Function-style overrides should be resolved in plugin.ts before reaching here\n let finalFields: FieldConfig[];\n\n if (Array.isArray(additionalFields) && additionalFields.length > 0) {\n // Merge: append additional fields to defaults\n finalFields = [...defaultFields, ...additionalFields];\n } else {\n finalFields = defaultFields;\n }\n\n // Convert hyphenated slug to underscored table name (e.g., \"form-submissions\" -> \"form_submissions\")\n const dbName = slug.replace(/-/g, \"_\");\n\n return {\n slug,\n dbName,\n labels,\n fields: finalFields,\n timestamps: true,\n\n // Submissions carry visitor-entered answers plus ipAddress/userAgent, so the\n // default `entry.*` events stay suppressed — otherwise every submission would\n // be delivered to any endpoint subscribed to `entry.created` or `*`. Instead\n // emit a curated `form.submission.created` carrying ONLY safe metadata (which\n // form, when, and the status) — never the answers, IP, or user agent.\n webhooks: {\n record: false,\n emit: {\n event: \"form.submission.created\",\n fields: [\"form\", \"submittedAt\", \"status\"],\n },\n },\n\n admin: {\n isPlugin: true,\n group: \"Forms\",\n order: 51,\n useAsTitle: \"id\",\n description: \"View and manage form submissions\",\n // Submissions are created by visitors submitting forms — a hand-typed\n // \"New Submission\" would be fiction, so the admin never offers one.\n disableCreate: true,\n\n components: {\n // The full submissions experience (per-form columns, status tabs,\n // drawer detail, export) replaces the generic list.\n views: {\n List: {\n Component: \"@nextlyhq/plugin-form-builder/admin#SubmissionsView\",\n },\n },\n },\n\n ...overrides.admin,\n },\n\n // Access control with sensible defaults\n // Public can create (submit forms); reading, updating and deleting need the\n // matching permission on the caller's role (`read-form-submissions`, ...).\n // `create: true` — a visitor submits without an account. Reading, updating\n // and deleting are the shared defaults.\n access: accessWithDefaults(accessOverrides, { create: true }),\n\n hooks: {\n // Auto-set submittedAt timestamp on creation. (The edit stamp for\n // updates is registered in plugin.ts init — direct registry hooks are\n // the path that runs for every API surface.)\n beforeValidate: [\n (context: HookContext) => {\n const { data, operation } = context;\n if (operation === \"create\" && data && !data.submittedAt) {\n data.submittedAt = new Date();\n }\n return data;\n },\n ],\n\n afterChange: [\n (context: HookContext) => {\n const { data } = context;\n return data;\n },\n ],\n },\n\n // Spread any additional overrides (excluding already used properties)\n ...overrides,\n };\n}\n","/**\n * Reading a stored row as one of this plugin's documents.\n *\n * The services layer answers with a loose row -- `id`, `createdAt`, `updatedAt`\n * and an `unknown` index signature -- while this plugin declares concrete\n * documents. There is no overlap for TypeScript to check between the two, so\n * the conversion goes through `unknown` and **nothing here is verified at\n * compile time**. A single assertion does not compile:\n *\n * ```\n * error TS2352: Conversion of type 'CollectionEntry' to type\n * 'SubmissionDocument' may be a mistake because neither type sufficiently\n * overlaps with the other.\n * ```\n *\n * The parameters are therefore `unknown`, which is what the rows honestly are\n * at this boundary. One assertion from `unknown` is legal where two from a\n * partially-known shape were needed, so the widening removes a cast rather\n * than adding one -- but it removes the notation, not the risk.\n *\n * The point of this module is therefore not safety it cannot provide. It is\n * that the unchecked step happens in ONE place a reviewer can find, rather than\n * at six call sites where it reads as ordinary code.\n *\n * @module document-shapes\n */\n\nimport type { FormDocument, SubmissionDocument } from \"./types\";\n\n/**\n * A row as the services layer hands it over.\n *\n * `unknown` rather than `Record<string, unknown>`: the read paths type their\n * results loosely enough that a narrower parameter only moves the assertion to\n * the call site, which is the thing this module exists to stop.\n */\ntype StoredRow = unknown;\n\n/**\n * Read a stored row as a submission.\n *\n * Does not validate, and deliberately cannot throw: every caller is on a path\n * where the row has already committed, so rejecting here would turn a saved\n * submission into an error the visitor is invited to retry.\n */\nexport function asSubmissionDocument(row: StoredRow): SubmissionDocument {\n return row as SubmissionDocument;\n}\n\n/** Read a list of stored rows as submissions. */\nexport function asSubmissionDocuments(\n rows: readonly StoredRow[]\n): SubmissionDocument[] {\n return rows as SubmissionDocument[];\n}\n\n/** Read a stored row as a form. Same contract as {@link asSubmissionDocument}. */\nexport function asFormDocument(row: StoredRow): FormDocument {\n return row as FormDocument;\n}\n","/**\n * What a submission has to be before it is stored, wherever it came from.\n *\n * Two things accept a submission. `submitForm` answers `POST\n * /api/forms/:slug/submit`, and `nextly.forms.submit()` is the Direct API a\n * server-side caller reaches through `getNextly()`. The first transformed,\n * validated and sanitized; the second did none of it, so a submission arriving\n * that way was stored with undeclared keys, values that did not match the\n * form's own schema, and markup intact.\n *\n * The rule lives here rather than in either caller because a rule stated in one\n * of two places is a rule the other can disagree with, and the two had already\n * drifted that far apart. `beforeCreate` on the submissions collection now asks\n * this on every write, so a third caller inherits it without knowing it exists.\n *\n * What is NOT here is the spam machinery: the honeypot, the rate limiter and\n * reCAPTCHA are facts about a REQUEST, not about a row. A trusted server\n * importing a thousand submissions would be rate-limited by its own import, and\n * a honeypot field means nothing without a browser to have skipped it. Those\n * stay on the HTTP path.\n */\n\nimport { stripHtmlTags } from \"nextly\";\n\nimport type { AnyFormField } from \"../types\";\nimport {\n generateZodSchema,\n getValidationErrors,\n getValidationIssues,\n type ValidationIssue,\n transformFormData,\n} from \"../utils/generate-schema\";\n\n/**\n * Form field types that accept free-text string input from end users.\n * These fields can contain HTML injection vectors and must be sanitized.\n *\n * Fields NOT in this set (select, radio, checkbox, number, date, time, file)\n * are constrained by Zod enum/type validation and don't need sanitization.\n */\nconst TEXT_FORM_FIELDS = new Set([\n \"text\",\n \"email\",\n \"textarea\",\n \"phone\",\n \"url\",\n \"hidden\",\n]);\n\n/**\n * Sanitize form submission data by stripping HTML tags from free-text fields.\n *\n * Iterates over the form's field definitions and applies `stripHtmlTags()`\n * to values whose field type is in `TEXT_FORM_FIELDS`. Non-string values\n * and constrained fields (select, radio, checkbox, etc.) are left unchanged.\n *\n * Mutates the data object in place for efficiency.\n *\n * @param data - Validated submission data (mutated in place)\n * @param fields - Form field definitions (used for type-aware dispatch)\n */\nexport function sanitizeSubmissionData(\n data: Record<string, unknown>,\n fields: AnyFormField[]\n): void {\n for (const field of fields) {\n // Plugin field types are not in TEXT_FORM_FIELDS, so they skip naturally.\n if (!TEXT_FORM_FIELDS.has(field.type)) continue;\n\n const value = data[field.name];\n if (typeof value !== \"string\") continue;\n\n data[field.name] = stripHtmlTags(value);\n }\n}\n\n/**\n * How the plugin's own submit handler says a row is its own.\n *\n * The write-seam check has to be lenient in exactly one case: a honeypot or\n * reCAPTCHA hit is stored flagged rather than dropped, so a false positive stays\n * reviewable, and requiring it to be valid would throw away the thing being\n * reviewed. Deciding that from the row's own `status` made it caller-controlled:\n * the submissions collection grants public create, nothing restricts `status`,\n * so anyone could post `status: \"spam\"` and switch validation off.\n *\n * A SYMBOL key on the row is the channel, and it is the only one that both\n * reaches the hook and cannot be reached from a request. `JSON.parse` never\n * produces a symbol key, so nothing a caller posts can carry it, and\n * `JSON.stringify` ignores it, so it cannot be stored by accident. It travels\n * with the row it describes, which is what the alternatives could not do.\n *\n * The alternatives were measured, not assumed. `createEntry`'s `params.context`\n * is unreachable: `wrapCollectionsForPlugin` rebuilds the trailing argument as\n * `{ user, overrideAccess }` and drops everything else. `AsyncLocalStorage`\n * reaches the hook but describes the CALL rather than the row, so a submission\n * written by another hook inside the same call took the exception, and\n * identifying the intended row by its content broke as soon as a host hook\n * normalised the payload, since core rebuilds the payload object on the way.\n * A symbol on the row has neither problem: booting a real Nextly and reading\n * the hook's context back, the row's symbol arrives and the payload's does not.\n */\nconst PLUGIN_SUBMISSION = Symbol.for(\"nextly.plugin-form-builder.submission\");\n\n/** What the handler says about the row it is writing. */\nexport interface SubmissionOriginMarks {\n /**\n * The handler decided this row is spam and is keeping it as evidence, so the\n * write seam sanitizes it but does not require it to be valid.\n */\n keepAsEvidence: boolean;\n /**\n * The parent form the handler has already read, so the write seam can check\n * the payload against it without reading it again. Used only when its id is\n * the one the row names.\n */\n form?: Record<string, unknown>;\n}\n\n/** Mark `row` as this plugin's own write, and hand it back. */\nexport function asPluginSubmission(\n row: Record<string, unknown>,\n marks: SubmissionOriginMarks\n): Record<string, unknown> {\n return Object.defineProperty(row, PLUGIN_SUBMISSION, {\n value: marks,\n enumerable: true,\n configurable: true,\n });\n}\n\n/** A row as it looks once this plugin has marked it. */\ninterface MarkedSubmission {\n [PLUGIN_SUBMISSION]?: SubmissionOriginMarks;\n}\n\n/** What this row was marked as, or nothing when it is somebody else's. */\nexport function submissionMarks(\n row: Record<string, unknown>\n): SubmissionOriginMarks | undefined {\n return (row as MarkedSubmission)[PLUGIN_SUBMISSION];\n}\n\n/** A form value, compared the way a form value can be. */\nfunction sameValue(left: unknown, right: unknown): boolean {\n if (Object.is(left, right)) return true;\n if (left instanceof Date && right instanceof Date) {\n return left.getTime() === right.getTime();\n }\n if (Array.isArray(left) && Array.isArray(right)) {\n return (\n left.length === right.length &&\n left.every((value, index) => sameValue(value, right[index]))\n );\n }\n return false;\n}\n\n/** Whether two submitted payloads say the same thing. */\nexport function sameSubmittedPayload(\n granted: Record<string, unknown>,\n incoming: Record<string, unknown>\n): boolean {\n const answers = Object.entries(granted);\n if (answers.length !== Object.keys(incoming).length) return false;\n return answers.every(([field, answer]) => sameValue(answer, incoming[field]));\n}\n\n/** A submission ready to store, or the reasons it is not. */\nexport interface PreparedSubmission {\n /** Transformed and sanitized. Present whether or not validation passed. */\n data: Record<string, unknown>;\n /**\n * Field name to its first message, which is the shape `getValidationErrors`\n * already returns and the HTTP handler already answers with. Absent when the\n * submission is storable.\n */\n validationErrors?: Record<string, string>;\n /**\n * The same failures carrying their machine codes.\n *\n * Beside the map rather than replacing it: the map is this plugin's public\n * result shape, while an HTTP route needs the code to report `REQUIRED` for a\n * blank answer the way the endpoint always has. Both come from one walk of\n * the issues, so they cannot disagree.\n */\n validationIssues?: ValidationIssue[];\n}\n\n/**\n * Bring a submission to the shape the form declares.\n *\n * Transform, then sanitize, then validate, in that order and for reasons.\n * Transforming projects the payload onto the declared fields, so an undeclared\n * key never reaches the schema or the row. Sanitizing before validating is what\n * makes every rule judge the value that will actually be stored: the other\n * order let `<b></b>` satisfy a required field and then reduced it to an empty\n * string, and made a length rule count markup the visitor never sees.\n *\n * `validate: false` is for content the plugin has already decided to keep as\n * evidence. A honeypot hit is stored flagged rather than dropped so a false\n * positive stays recoverable, and requiring it to be valid would throw the\n * evidence away. It is still transformed and sanitized, because a row nobody\n * validated is exactly the one that should not carry markup.\n *\n * Idempotent, which is what lets the write-seam hook run over data the HTTP\n * handler has already prepared: transforming a projected payload projects it\n * again, and stripping tags from text that has none returns it unchanged.\n */\nexport function prepareSubmission({\n data,\n fields,\n validate,\n}: {\n data: Record<string, unknown>;\n fields: AnyFormField[];\n validate: boolean;\n}): PreparedSubmission {\n const transformed = transformFormData(data, fields);\n sanitizeSubmissionData(transformed, fields);\n\n if (!validate) {\n return { data: transformed };\n }\n\n const result = generateZodSchema(fields).safeParse(transformed);\n if (!result.success) {\n // The transformed payload comes back with the errors rather than nothing,\n // so a caller that wants to store it anyway has the same value the valid\n // path would have produced, minus the schema's blessing.\n return {\n data: transformed,\n validationErrors: getValidationErrors(result),\n // The RAW submission, not the transformed payload. Transformation\n // normalises a value before the schema sees it, so a required\n // multi-select sent a scalar `0` arrives here as `[]` and would read as\n // blank: the visitor answered, and would be told they had not.\n validationIssues: getValidationIssues(result, data),\n };\n }\n\n return { data: result.data };\n}\n","/**\n * Dynamic Zod Schema Generator\n *\n * Converts FormField[] configuration to Zod validation schemas.\n * Supports all form field types with proper validation rules.\n *\n * @module utils/generate-schema\n * @since 0.1.0\n */\n\nimport type { FieldValidationRule } from \"nextly/field-catalog\";\nimport { z } from \"zod\";\n\nimport { isKnownFormField } from \"../types\";\nimport type {\n AnyFieldValidation,\n AnyFormField,\n FormField,\n TextFormField,\n EmailFormField,\n NumberFormField,\n PhoneFormField,\n UrlFormField,\n TextareaFormField,\n SelectFormField,\n CheckboxFormField,\n RadioFormField,\n FileFormField,\n DateFormField,\n TimeFormField,\n HiddenFormField,\n} from \"../types\";\n\nimport { ENFORCED_VALIDATION_RULES } from \"./enforced-validation\";\n\n// ============================================================\n// Main Schema Generator\n// ============================================================\n\n/**\n * Generate a Zod validation schema from form field configuration.\n *\n * Creates a dynamic Zod schema based on the form fields array,\n * applying appropriate validation rules for each field type.\n *\n * @param fields - Array of form field configurations\n * @returns Zod object schema for validating submission data\n *\n * @example\n * ```typescript\n * const fields: FormField[] = [\n * { type: 'text', name: 'firstName', label: 'First Name', required: true },\n * { type: 'email', name: 'email', label: 'Email', required: true },\n * { type: 'number', name: 'age', label: 'Age', validation: { min: 18, max: 120 } },\n * ];\n *\n * const schema = generateZodSchema(fields);\n * const result = schema.safeParse({ firstName: 'John', email: 'john@example.com', age: 25 });\n * ```\n */\nexport function generateZodSchema(\n fields: AnyFormField[]\n): z.ZodObject<Record<string, z.ZodTypeAny>> {\n const schemaShape: Record<string, z.ZodTypeAny> = {};\n\n for (const field of fields) {\n // A plugin-contributed field's value shape is owned by the plugin: accept\n // any value, but keep the base required check so a required plugin field\n // cannot be omitted (z.unknown() alone treats a missing key as valid).\n if (!isKnownFormField(field)) {\n schemaShape[field.name] = field.required\n ? z\n .unknown()\n .refine(\n value => value !== undefined && value !== null && value !== \"\",\n { message: `${field.label || field.name} is required` }\n )\n : z.unknown();\n continue;\n }\n\n // Skip hidden fields without required flag\n if (field.type === \"hidden\" && !field.required) {\n // Still add to schema but make optional\n schemaShape[field.name] = z.string().optional();\n continue;\n }\n\n const fieldSchema = generateFieldSchema(field);\n if (fieldSchema) {\n schemaShape[field.name] = fieldSchema;\n }\n }\n\n return z.object(schemaShape);\n}\n\n// ============================================================\n// Field Schema Generators\n// ============================================================\n\n/**\n * Generate a Zod schema for a single field.\n */\nfunction generateFieldSchema(field: FormField): z.ZodTypeAny | null {\n switch (field.type) {\n case \"text\":\n return generateTextSchema(field);\n case \"email\":\n return generateEmailSchema(field);\n case \"number\":\n return generateNumberSchema(field);\n case \"phone\":\n return generatePhoneSchema(field);\n case \"url\":\n return generateUrlSchema(field);\n case \"textarea\":\n return generateTextareaSchema(field);\n case \"select\":\n return generateSelectSchema(field);\n case \"checkbox\":\n return generateCheckboxSchema(field);\n case \"radio\":\n return generateRadioSchema(field);\n case \"file\":\n return generateFileSchema(field);\n case \"date\":\n return generateDateSchema(field);\n case \"time\":\n return generateTimeSchema(field);\n case \"hidden\":\n return generateHiddenSchema(field);\n default:\n // Unreachable for a known FormField; plugin types are handled in\n // generateZodSchema before this switch is reached.\n return z.unknown();\n }\n}\n\n/**\n * Generate schema for text field.\n */\n/**\n * The author's own wording for a failure, or `undefined` when this field's type\n * does not enforce `message`.\n *\n * Asked of the row rather than read off `validation` directly, for the reason\n * the table exists at all: the editor decides whether to OFFER the control from\n * that row, so a generator honouring a stored message regardless would enforce\n * a value the author was never shown a box for — and, worse, the table would\n * describe a rule it did not govern.\n *\n * `message` was the one rule that stayed outside this. It agreed with the table\n * only because every row happens to list it, which is agreement by coincidence:\n * drop `message` from a row and the control disappears while the wording went\n * on being applied.\n */\nfunction customMessage(field: FormField): string | undefined {\n const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];\n if (!enforced.includes(\"message\")) return undefined;\n return field.validation?.errorMessage;\n}\n\n/**\n * A field's stored bounds, with the custom message already resolved by the row.\n *\n * Handed to the appliers so they cannot read an ungated message: they take a\n * validation object, and giving them one whose `errorMessage` has been through\n * {@link customMessage} makes the rule hold for every applier without each of\n * them having to remember it.\n */\nfunction enforcedValidation(field: FormField): AnyFieldValidation {\n const stored = (field.validation ?? {}) as AnyFieldValidation;\n return { ...stored, errorMessage: customMessage(field) };\n}\n\n/**\n * Which rules a type's schema applies, and how each one constrains a string.\n *\n * The WHICH comes from {@link ENFORCED_VALIDATION_RULES}, which the field\n * editor also reads to decide what to offer. One definition answers both\n * questions, so a rule cannot be offered by the editor and ignored here, or\n * honoured here and hidden there — the two used to state the same thing\n * separately and could only agree by being edited together.\n *\n * The HOW stays here, because it is about zod and the table is deliberately\n * free of it: the editor imports the table, and pulling the schema builder in\n * behind it would ship this file and its validation library to every admin\n * client before anyone opened a field.\n */\nconst STRING_RULE_APPLIERS: Partial<\n Record<\n FieldValidationRule,\n (\n schema: z.ZodString,\n validation: AnyFieldValidation,\n fallback: string\n ) => z.ZodString\n >\n> = {\n minLength: (schema, validation) =>\n validation.minLength === undefined\n ? schema\n : schema.min(\n validation.minLength,\n validation.errorMessage ||\n `Minimum ${validation.minLength} characters required`\n ),\n maxLength: (schema, validation) =>\n validation.maxLength === undefined\n ? schema\n : schema.max(\n validation.maxLength,\n validation.errorMessage ||\n `Maximum ${validation.maxLength} characters allowed`\n ),\n pattern: (schema, validation, fallback) =>\n validation.pattern\n ? schema.regex(\n new RegExp(validation.pattern),\n validation.errorMessage || fallback\n )\n : schema,\n};\n\n/** The same, for the bounds a number carries. */\nconst NUMBER_RULE_APPLIERS: Partial<\n Record<\n FieldValidationRule,\n (schema: z.ZodNumber, validation: AnyFieldValidation) => z.ZodNumber\n >\n> = {\n min: (schema, validation) =>\n validation.min === undefined\n ? schema\n : schema.min(\n validation.min,\n validation.errorMessage || `Minimum value is ${validation.min}`\n ),\n max: (schema, validation) =>\n validation.max === undefined\n ? schema\n : schema.max(\n validation.max,\n validation.errorMessage || `Maximum value is ${validation.max}`\n ),\n};\n\n/**\n * Whether an author-supplied pattern actually replaces a type's intrinsic one.\n *\n * Two conditions, and both are load-bearing: a pattern must be stored, AND the\n * type's row must enforce `pattern`. A type that keeps its own shape check —\n * a phone, a URL — stands it down only for an override that will really be\n * applied, because standing down for one that will not leaves nothing behind.\n */\nfunction overridesPattern(field: FormField): boolean {\n const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];\n if (!enforced.includes(\"pattern\")) return false;\n const validation = (field.validation ?? {}) as AnyFieldValidation;\n return Boolean(validation.pattern);\n}\n\n/**\n * Constrain a string schema by the rules this field's TYPE enforces.\n *\n * A rule the type does not list is not applied even when the field carries a\n * value for it, which is the point: a `pattern` stored on a textarea is a bound\n * the editor never offered, and honouring it here would enforce a restriction\n * its author could not see.\n *\n * `patternMessage` is the type's own wording for a failed pattern — \"Invalid\n * URL format\" reads differently from \"Invalid format\" — so the rule set decides\n * WHICH constraints apply while each type keeps its own voice.\n */\nfunction applyStringRules(\n schema: z.ZodString,\n field: FormField,\n patternMessage: string\n): z.ZodString {\n const validation = enforcedValidation(field);\n let constrained = schema;\n for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {\n const apply = STRING_RULE_APPLIERS[rule];\n if (apply) constrained = apply(constrained, validation, patternMessage);\n }\n return constrained;\n}\n\nfunction generateTextSchema(field: TextFormField): z.ZodTypeAny {\n const schema = applyStringRules(z.string(), field, \"Invalid format\");\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for email field.\n */\nfunction generateEmailSchema(field: EmailFormField): z.ZodTypeAny {\n // The format check is the type's own, not a validation rule: an email field\n // is an email field whether or not its author bounded it further.\n const schema = applyStringRules(\n z.string().email(customMessage(field) || \"Invalid email address\"),\n field,\n \"Invalid email format\"\n );\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for number field.\n */\nfunction generateNumberSchema(field: NumberFormField): z.ZodTypeAny {\n // Zod 4 unified message customization under a single `error` callback;\n // an undefined input is the \"missing required\" case, anything else is a\n // type mismatch.\n let schema = z.number({\n error: issue =>\n issue.input === undefined\n ? customMessage(field) || \"This field is required\"\n : \"Must be a number\",\n });\n\n // The same rule set the editor reads decides which bounds apply here.\n const validation = enforcedValidation(field);\n for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {\n const apply = NUMBER_RULE_APPLIERS[rule];\n if (apply) schema = apply(schema, validation);\n }\n\n if (field.required) {\n return schema;\n }\n\n return schema.optional().nullable();\n}\n\n/**\n * Generate schema for phone field.\n */\nfunction generatePhoneSchema(field: PhoneFormField): z.ZodTypeAny {\n // The default pattern is part of what a phone field IS, so it belongs to the\n // base rather than to the rule set: it applies when the author supplied none,\n // and the author's own pattern replaces it through the rule below.\n //\n // Whether the author CAN replace it is the row's decision, not the stored\n // value's. Standing the base down because a pattern happens to be stored,\n // while `applyStringRules` then declines to apply it because the row does not\n // list `pattern`, would leave the field with no check at all — every string\n // accepted, on the one type whose whole purpose is a shape.\n const base = overridesPattern(field)\n ? z.string()\n : z\n .string()\n .regex(\n /^[\\d\\s\\-+()]+$/,\n customMessage(field) || \"Invalid phone number\"\n );\n\n const schema = applyStringRules(base, field, \"Invalid phone number format\");\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for URL field.\n */\nfunction generateUrlSchema(field: UrlFormField): z.ZodTypeAny {\n const schema = applyStringRules(\n z.string().url(customMessage(field) || \"Invalid URL\"),\n field,\n \"Invalid URL format\"\n );\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for textarea field.\n */\nfunction generateTextareaSchema(field: TextareaFormField): z.ZodTypeAny {\n const schema = applyStringRules(z.string(), field, \"Invalid format\");\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for select field.\n */\nfunction generateSelectSchema(field: SelectFormField): z.ZodTypeAny {\n const validValues = field.options.map(opt => opt.value);\n\n if (field.allowMultiple) {\n // Multi-select: array of valid values\n const schema = z.array(z.enum(validValues as [string, ...string[]]));\n\n if (field.required) {\n return schema.min(\n 1,\n customMessage(field) || \"Please select at least one option\"\n );\n }\n\n return schema.optional();\n }\n\n // Single select\n const schema = z.enum(validValues as [string, ...string[]], {\n error: () => customMessage(field) || \"Please select a valid option\",\n });\n\n if (field.required) {\n return schema;\n }\n\n return schema.optional();\n}\n\n/**\n * Generate schema for checkbox field.\n */\nfunction generateCheckboxSchema(field: CheckboxFormField): z.ZodTypeAny {\n const schema = z.boolean();\n\n if (field.required) {\n // For required checkbox, value must be true\n return schema.refine(val => val === true, {\n message: customMessage(field) || \"This field is required\",\n });\n }\n\n return schema.optional();\n}\n\n/**\n * Generate schema for radio field.\n */\nfunction generateRadioSchema(field: RadioFormField): z.ZodTypeAny {\n const validValues = field.options.map(opt => opt.value);\n\n const schema = z.enum(validValues as [string, ...string[]], {\n error: () => customMessage(field) || \"Please select an option\",\n });\n\n if (field.required) {\n return schema;\n }\n\n return schema.optional();\n}\n\n/**\n * Generate schema for file field.\n *\n * Note: File validation is typically handled server-side.\n * This schema validates file references (IDs or URLs).\n */\nfunction generateFileSchema(field: FileFormField): z.ZodTypeAny {\n if (field.multiple) {\n const schema = z.array(z.string());\n\n if (field.required) {\n return schema.min(\n 1,\n customMessage(field) || \"Please upload at least one file\"\n );\n }\n\n return schema.optional();\n }\n\n // Single file\n const schema = z.string();\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for date field.\n */\nfunction generateDateSchema(field: DateFormField): z.ZodTypeAny {\n // Start with base string schema\n let schema: z.ZodTypeAny = z.string();\n\n // Add date format validation\n schema = (schema as z.ZodString).refine(\n val => {\n if (!val) return true; // Allow empty for optional fields\n const date = new Date(val);\n return !isNaN(date.getTime());\n },\n { message: customMessage(field) || \"Invalid date\" }\n );\n\n // Min/max date validation\n if (field.min || field.max) {\n schema = schema.refine(\n (val: unknown) => {\n if (!val) return true;\n const date = new Date(val as string);\n if (field.min && date < new Date(field.min)) return false;\n if (field.max && date > new Date(field.max)) return false;\n return true;\n },\n {\n message:\n customMessage(field) ||\n `Date must be between ${field.min || \"any\"} and ${field.max || \"any\"}`,\n }\n );\n }\n\n // Handle required vs optional\n if (field.required) {\n // Add non-empty check for required fields\n return schema.refine(\n (val: unknown) => val !== undefined && val !== null && val !== \"\",\n { message: customMessage(field) || \"This field is required\" }\n );\n }\n\n return schema.optional().or(z.literal(\"\"));\n}\n\n/**\n * Generate schema for time field.\n */\nfunction generateTimeSchema(field: TimeFormField): z.ZodTypeAny {\n const schema = z\n .string()\n .regex(\n /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/,\n customMessage(field) || \"Invalid time format (HH:mm)\"\n );\n\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n/**\n * Generate schema for hidden field.\n */\nfunction generateHiddenSchema(field: HiddenFormField): z.ZodTypeAny {\n const schema = z.string();\n return applyRequired(schema, field.required, customMessage(field));\n}\n\n// ============================================================\n// Helper Functions\n// ============================================================\n\n/**\n * Apply required/optional to a schema.\n */\nfunction applyRequired(\n schema: z.ZodString,\n required?: boolean,\n errorMessage?: string\n): z.ZodTypeAny {\n if (required) {\n return schema.min(1, errorMessage || \"This field is required\");\n }\n\n return schema.optional().or(z.literal(\"\"));\n}\n\n// ============================================================\n// Data Transformation\n// ============================================================\n\n/**\n * Transform form data before validation.\n *\n * Handles type coercion and normalization:\n * - Converts string numbers to actual numbers\n * - Converts string booleans to actual booleans\n * - Trims string values\n * - Handles empty strings\n *\n * @param data - Raw form data\n * @param fields - Form field configurations\n * @returns Transformed data ready for validation\n */\nexport function transformFormData(\n data: Record<string, unknown>,\n fields: AnyFormField[]\n): Record<string, unknown> {\n const transformed: Record<string, unknown> = {};\n\n for (const field of fields) {\n const value = data[field.name];\n\n // Skip undefined values\n if (value === undefined) {\n continue;\n }\n\n // A plugin field's value passes through untransformed — the plugin owns its\n // value shape; only built-in types get type coercion.\n transformed[field.name] = isKnownFormField(field)\n ? transformFieldValue(value, field)\n : value;\n }\n\n return transformed;\n}\n\n/**\n * Transform a single field value based on its type.\n */\nfunction transformFieldValue(value: unknown, field: FormField): unknown {\n // Handle null/undefined\n if (value === null || value === undefined) {\n return value;\n }\n\n switch (field.type) {\n case \"number\":\n // Coerce to number\n if (typeof value === \"string\") {\n const num = parseFloat(value);\n return isNaN(num) ? value : num;\n }\n return value;\n\n case \"checkbox\":\n // Coerce to boolean\n if (typeof value === \"string\") {\n return value === \"true\" || value === \"1\" || value === \"on\";\n }\n return Boolean(value);\n\n case \"text\":\n case \"email\":\n case \"phone\":\n case \"url\":\n case \"textarea\":\n case \"hidden\":\n // Trim strings\n if (typeof value === \"string\") {\n return value.trim();\n }\n return value;\n\n case \"select\":\n // Ensure array for multi-select\n if (field.allowMultiple) {\n if (!Array.isArray(value)) {\n return value ? [value] : [];\n }\n }\n return value;\n\n default:\n return value;\n }\n}\n\n// ============================================================\n// Validation Helper\n// ============================================================\n\n/**\n * Validate form data against a form's field configuration.\n *\n * Combines transformation and validation in one step.\n *\n * @param data - Raw form data\n * @param fields - Form field configurations\n * @returns Zod safe parse result\n *\n * @example\n * ```typescript\n * const result = validateFormData({ name: 'John', email: 'john@example.com' }, fields);\n *\n * if (result.success) {\n * console.log('Valid data:', result.data);\n * } else {\n * console.log('Validation errors:', result.error.flatten());\n * }\n * ```\n */\nexport function validateFormData(\n data: Record<string, unknown>,\n fields: AnyFormField[]\n): z.ZodSafeParseResult<Record<string, unknown>> {\n const transformed = transformFormData(data, fields);\n const schema = generateZodSchema(fields);\n return schema.safeParse(transformed);\n}\n\n/**\n * Get validation errors as a flat object.\n *\n * @param result - Zod safe parse result\n * @returns Object mapping field names to error messages\n */\n/** A field that failed, with the machine code a wire error reports. */\nexport interface ValidationIssue {\n path: string;\n /** `REQUIRED` for an answer that is missing or empty, else `INVALID`. */\n code: \"REQUIRED\" | \"INVALID\";\n message: string;\n}\n\n/**\n * Whether the visitor supplied nothing at this field.\n *\n * The same four shapes the endpoint has always counted as blank. A `false`\n * checkbox and a `0` are answers, so neither is absent.\n */\nfunction isBlank(value: unknown): boolean {\n return (\n value === undefined ||\n value === null ||\n value === \"\" ||\n (Array.isArray(value) && value.length === 0)\n );\n}\n\n/**\n * The failures with their codes, first one per field.\n *\n * A code as well as a sentence, because the public submission error has always\n * carried one and a client uses it to tell \"you left this blank\" from \"this is\n * not an email\" without parsing English. Flattening every failure to `INVALID`\n * silently stopped required-field detection working for those clients.\n *\n * The code is decided by looking at the SUBMITTED VALUE, not at the Zod issue.\n * Zod's codes do not divide the same way: `too_small` covers a blank answer and\n * a four-character password under a minimum of eight, and `invalid_type` covers\n * an absent key and a string sent to a number field. Reading the code alone\n * told a visitor to fill in a field they had already filled in.\n */\nexport function getValidationIssues(\n result: z.ZodSafeParseResult<unknown>,\n submitted: Record<string, unknown> = {}\n): ValidationIssue[] {\n if (result.success) return [];\n\n const seen = new Set<string>();\n const issues: ValidationIssue[] = [];\n\n // Zod 4 renamed ZodError.errors to ZodError.issues.\n for (const issue of result.error.issues) {\n const path = issue.path.join(\".\");\n if (seen.has(path)) continue;\n seen.add(path);\n issues.push({\n path,\n // Read at the top level, which is the only depth a form field has: a\n // declared field is one key, so a dotted path can only come from inside a\n // value the visitor did supply.\n code: isBlank(submitted[path]) ? \"REQUIRED\" : \"INVALID\",\n message: issue.message,\n });\n }\n\n return issues;\n}\n\n/**\n * The same failures as a field-to-message map.\n *\n * Derived from {@link getValidationIssues} rather than walking the issues a\n * second time, so the two cannot disagree about which message belongs to a\n * field or which failure won when one field had several.\n */\nexport function getValidationErrors(\n result: z.ZodSafeParseResult<unknown>\n): Record<string, string> {\n return Object.fromEntries(\n getValidationIssues(result).map(issue => [issue.path, issue.message])\n );\n}\n","/**\n * Spam Detection\n *\n * Honeypot and rate limiting for form submissions.\n *\n * The honeypot is a pure read of the submitted payload. The rate limit is not:\n * it counts, and where the count lives decides whether the limit means\n * anything. It lives in the deployment's own {@link RateLimitStore}, the one\n * the REST and auth limiters already share, so `rateLimit.store` is configured\n * once and all three agree about what they are counting. A store private to\n * this module would count per process, and on a deployment that spans several\n * the effective limit becomes `configured x instances` -- a number the operator\n * never chose, that fails open, and only under load.\n *\n * @module handlers/spam-detection\n * @since 0.1.0\n */\n\nimport { RateLimiter, resolveRateLimitStore } from \"nextly\";\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Configuration for spam detection.\n */\nexport interface SpamCheckConfig {\n /**\n * Enable honeypot field detection.\n * When enabled, submissions with filled honeypot fields are marked as spam.\n */\n honeypot?: boolean;\n\n /**\n * Rate limiting configuration.\n */\n rateLimit?: {\n /** Maximum submissions allowed per window */\n maxSubmissions: number;\n /** Time window in milliseconds */\n windowMs: number;\n };\n\n /**\n * reCAPTCHA configuration (for future implementation).\n */\n recaptcha?: {\n enabled: boolean;\n secretKey?: string;\n scoreThreshold?: number;\n };\n}\n\n/**\n * Options for spam check.\n */\nexport interface SpamCheckOptions {\n /** Form submission data */\n data: Record<string, unknown>;\n\n /** Submitter's IP address */\n ipAddress?: string;\n\n /** Form slug for rate limiting key */\n formSlug: string;\n\n /** Spam protection configuration */\n config: SpamCheckConfig;\n}\n\n/**\n * Result of spam check.\n */\nexport interface SpamCheckResult {\n /** Whether the submission is detected as spam */\n isSpam: boolean;\n\n /** Reason for spam detection (for logging, not exposed to user) */\n reason?: \"honeypot\" | \"rate_limit\" | \"recaptcha\";\n\n /** Additional details (for logging) */\n details?: string;\n}\n\n// ============================================================\n// Main Spam Check Function\n// ============================================================\n\n/**\n * Check a form submission for spam indicators.\n *\n * Performs multiple spam checks based on configuration:\n * 1. Honeypot field detection\n * 2. Rate limiting by IP/form\n * 3. reCAPTCHA verification (future)\n *\n * @param options - Spam check options\n * @returns Spam check result\n *\n * @example\n * ```typescript\n * const result = await checkSpam({\n * data: formData,\n * ipAddress: req.ip,\n * formSlug: 'contact-form',\n * config: {\n * honeypot: true,\n * rateLimit: { maxSubmissions: 5, windowMs: 60000 },\n * },\n * });\n *\n * if (result.isSpam) {\n * // Silently reject (return fake success to bot)\n * return { success: true };\n * }\n * ```\n */\nexport async function checkSpam(\n options: SpamCheckOptions\n): Promise<SpamCheckResult> {\n const { data, ipAddress, formSlug, config } = options;\n\n // 1. Honeypot check\n if (config.honeypot) {\n const honeypotResult = checkHoneypot(data);\n if (honeypotResult.isSpam) {\n return honeypotResult;\n }\n }\n\n // 2. Rate limiting check\n if (config.rateLimit && ipAddress) {\n const rateLimitResult = await checkRateLimit(\n ipAddress,\n formSlug,\n config.rateLimit\n );\n if (rateLimitResult.isSpam) {\n return rateLimitResult;\n }\n }\n\n return { isSpam: false };\n}\n\n// ============================================================\n// Honeypot Detection\n// ============================================================\n\n/**\n * Standard honeypot field names to check.\n * These fields should be hidden from users but filled by bots.\n */\nconst HONEYPOT_FIELDS = [\n \"__honeypot\",\n \"_honeypot\",\n \"honeypot\",\n \"__hp\",\n \"_hp\",\n \"website\", // Common honeypot field name\n \"url_field\", // Another common honeypot\n \"fax_number\", // Rarely used by humans\n];\n\n/**\n * Check for honeypot field presence.\n *\n * Honeypot fields are hidden form fields that legitimate users\n * won't see or fill, but bots often fill automatically.\n *\n * @param data - Form submission data\n * @returns Spam check result\n */\nfunction checkHoneypot(data: Record<string, unknown>): SpamCheckResult {\n for (const fieldName of HONEYPOT_FIELDS) {\n const value = data[fieldName];\n\n // Check if honeypot field has a non-empty value\n if (value !== undefined && value !== null && value !== \"\") {\n let raw: string;\n if (typeof value === \"object\") {\n raw = JSON.stringify(value);\n } else {\n // eslint-disable-next-line @typescript-eslint/no-base-to-string -- value narrowed to primitive above; rule doesn't follow control flow on unknown\n raw = String(value);\n }\n const stringValue = raw.trim();\n if (stringValue.length > 0) {\n return {\n isSpam: true,\n reason: \"honeypot\",\n details: `Honeypot field \"${fieldName}\" was filled`,\n };\n }\n }\n }\n\n return { isSpam: false };\n}\n\n// ============================================================\n// Rate Limiting\n// ============================================================\n\n/**\n * Whether this address has submitted this form too often, recording the attempt.\n *\n * Keyed by form and address together, so a burst against one form does not\n * lock a visitor out of another. The window lives in the deployment's shared\n * store, so two processes serving the same visitor count once.\n *\n * An address of `unknown` is not a bucket to key on: every client whose address\n * could not be resolved would share one window, and the first bot to fill it\n * would lock out every visitor behind an untrusted proxy. The caller decides\n * what to do about an unidentifiable client; this counts only identified ones.\n */\nasync function checkRateLimit(\n ipAddress: string,\n formSlug: string,\n config: { maxSubmissions: number; windowMs: number }\n): Promise<SpamCheckResult> {\n const limiter = new RateLimiter(resolveRateLimitStore());\n const result = await limiter.check(\n `form-submit:${formSlug}:${ipAddress}`,\n config.maxSubmissions,\n config.windowMs\n );\n if (result.allowed) return { isSpam: false };\n return {\n isSpam: true,\n reason: \"rate_limit\",\n details: `Rate limit exceeded: ${config.maxSubmissions} per ${config.windowMs}ms`,\n };\n}\n\n// ============================================================\n// reCAPTCHA Support (Future Implementation)\n// ============================================================\n\n/**\n * Verify reCAPTCHA v3 token.\n *\n * Note: This is a placeholder for future implementation.\n * reCAPTCHA verification requires server-side API calls to Google.\n *\n * @param data - Form data containing reCAPTCHA token\n * @param config - reCAPTCHA configuration\n * @returns Spam check result\n */\n// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/require-await -- declared return type is `Promise<SpamCheckResult>`; placeholder for unimplemented Google siteverify fetch\nasync function verifyRecaptcha(\n _data: Record<string, unknown>,\n _config: { secretKey?: string; scoreThreshold?: number }\n): Promise<SpamCheckResult> {\n // Placeholder - always pass for now\n return { isSpam: false };\n}\n","/**\n * The public form endpoints, served by the plugin that owns the collections.\n *\n * `GET /api/forms`, `GET /api/forms/:slug` and `POST /api/forms/:slug/submit`\n * were served by the core dispatcher, which read collections named `forms` and\n * `form-submissions` in four hardcoded places. Core defines neither, so those\n * endpoints only ever worked when this plugin was installed, and they broke\n * outright on any install that renamed a collection through the plugin's own\n * overrides. They also validated a submission BEFORE the write seam judged it,\n * so a bot that tripped the honeypot while omitting a required field received a\n * distinguishable validation error and left no evidence behind.\n *\n * Contributed at the root mount, so the addresses are unchanged: a client\n * already calling `/api/forms/contact/submit` keeps calling it. Root routes are\n * consulted only where the built-in router declines, which is why core's\n * parser had to stop claiming `/forms` in the same change rather than a later\n * one. Left in place, it would answer first and these would never be reached.\n *\n * The bodies are the canonical envelopes, built with core's own helpers rather\n * than assembled here. A route that rebuilt the shape by hand would preserve\n * the URL while changing the contract underneath every client already calling\n * it, and would silently drop the post-commit warnings `respondAction` carries.\n *\n * @module routes/public-forms\n */\n\nimport {\n buildPaginatedResponse,\n clampLimit,\n formAvailability,\n NextlyError,\n NO_SUCH_FORM,\n respondAction,\n respondDoc,\n respondList,\n trustedClientIp,\n type FormAvailabilityInput,\n type PaginationMeta,\n type PluginRoute,\n type PluginRouteContext,\n} from \"nextly\";\n\nimport { submitForm } from \"../handlers/submit-form\";\nimport type { ResolvedFormBuilderConfig } from \"../types\";\n\n/**\n * These routes read and write as the anonymous visitor on the other end.\n *\n * `public: true` waives the ROUTE's authentication and says nothing about the\n * collections behind it. Elevating to `system` here would hand a form to any\n * caller on an install whose host set `formOverrides.access.read` to something\n * narrower, and accept a submission on one that closed creation, which is the\n * host's configuration being silently overridden rather than obeyed.\n */\nconst PUBLIC = { as: \"public\" } as const;\n\n/** How many forms a page of the public listing holds when none is asked for. */\nconst DEFAULT_LIST_LIMIT = 100;\n\n/** What a visitor is told when the form's author wrote no message of their own. */\nconst DEFAULT_SUCCESS_MESSAGE = \"Thank you for your submission!\";\n\n/**\n * The collection names this install actually uses.\n *\n * A host may rename either collection through the plugin's overrides, and\n * `ctx.self.collections` maps what the plugin DECLARED to what was registered.\n * Reading the declared name directly is what made the core endpoints fail on a\n * renamed install while the plugin's own export route kept working.\n */\nfunction installedSlugs(\n config: ResolvedFormBuilderConfig,\n self: PluginRouteContext[\"self\"]\n): { forms: string; submissions: string } {\n const declaredForms = config.formOverrides.slug;\n const declaredSubmissions = config.formSubmissionOverrides.slug;\n return {\n forms: self.collections[declaredForms] ?? declaredForms,\n submissions: self.collections[declaredSubmissions] ?? declaredSubmissions,\n };\n}\n\n/**\n * The config `submitForm` should read on THIS install.\n *\n * That helper takes the declared slugs from configuration, which is right for a\n * host calling it directly and wrong for a route, where the registered names\n * are knowable. Handing it a config whose slugs are already resolved keeps one\n * submission path rather than a second one that reads different tables.\n */\nfunction configForInstall(\n config: ResolvedFormBuilderConfig,\n self: PluginRouteContext[\"self\"]\n): ResolvedFormBuilderConfig {\n const slugs = installedSlugs(config, self);\n return {\n ...config,\n formOverrides: { ...config.formOverrides, slug: slugs.forms },\n formSubmissionOverrides: {\n ...config.formSubmissionOverrides,\n slug: slugs.submissions,\n },\n };\n}\n\n/** A positive integer from a query string, or `undefined` when it is not one. */\nfunction positiveInt(raw: string | null): number | undefined {\n if (raw === null) return undefined;\n const value = Number(raw);\n return Number.isInteger(value) && value > 0 ? value : undefined;\n}\n\n/**\n * The service's offset-based pagination, as the wire's page-based meta.\n *\n * The page arithmetic is `buildPaginatedResponse`'s, not a second copy of it.\n * Its edge cases are the ones this endpoint has always answered with and are\n * easy to get subtly wrong alone: an empty collection reports ONE page rather\n * than zero, and a page past the end reports the clamped page rather than the\n * one that was asked for, so `?page=100` on a single-page list cannot answer\n * \"page 100 of 1\".\n */\nfunction toPageMeta(pagination: {\n total: number;\n limit: number;\n offset: number;\n}): PaginationMeta {\n const limit = pagination.limit > 0 ? pagination.limit : DEFAULT_LIST_LIMIT;\n const built = buildPaginatedResponse([], {\n total: pagination.total,\n page: Math.floor(pagination.offset / limit) + 1,\n limit,\n });\n return {\n total: built.totalDocs,\n page: built.page,\n limit: built.limit,\n totalPages: built.totalPages,\n hasNext: built.hasNextPage,\n hasPrev: built.hasPrevPage,\n };\n}\n\nasync function listPublishedForms(\n req: Request,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig\n): Promise<Response> {\n const url = new URL(req.url);\n const slugs = installedSlugs(config, ctx.self);\n\n // Published only. The listing is the one surface anyone can enumerate, so a\n // draft or closed form must not appear in it; the by-slug read below is what\n // explains a closed form, and only to a caller who already holds its slug.\n const result = await ctx.services.collections.listEntries(\n slugs.forms,\n {\n where: { status: { equals: \"published\" } },\n pagination: {\n // Clamped HERE, with the canonical bound, so the limit that computes\n // the offset is the limit that comes back. The query service clamps to\n // 500 internally and reports the clamped value, while the offset is\n // built from what was asked for, so `?limit=1000&page=2` returned the\n // second 500-row slice and dividing the two reported page 3.\n limit: clampLimit(\n positiveInt(url.searchParams.get(\"limit\")) ?? DEFAULT_LIST_LIMIT\n ),\n page: positiveInt(url.searchParams.get(\"page\")) ?? 1,\n },\n },\n PUBLIC\n );\n\n return respondList(result.data ?? [], toPageMeta(result.pagination));\n}\n\nasync function readFormBySlug(\n slug: string,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig\n): Promise<Response> {\n const slugs = installedSlugs(config, ctx.self);\n\n // Fetched by slug alone, then judged. A status filter in the query would\n // answer 404 for a CLOSED form as readily as for one that never existed, and\n // those are different answers: a visitor following a link to a closed form is\n // owed the reason its author wrote.\n const result = await ctx.services.collections.listEntries(\n slugs.forms,\n { where: { slug: { equals: slug } }, pagination: { limit: 1 } },\n PUBLIC\n );\n\n const doc = result.data?.[0] as FormAvailabilityInput | undefined;\n const availability = formAvailability(doc);\n\n if (availability.kind === \"absent\") {\n // The same sentence every other door gives. The canonical \"Not found.\"\n // would make what a visitor is told depend on which client they used.\n throw NextlyError.notFound({\n message: NO_SUCH_FORM,\n logContext: { entity: \"form\", slug, reason: availability.reason },\n });\n }\n\n if (availability.kind === \"closed\") {\n // Only what a visitor at this address needs. The author wrote the message\n // FOR them; nothing else on the row is theirs, and a closed form renders a\n // sentence rather than fields.\n return respondDoc({\n slug,\n status: \"closed\",\n closedMessage: availability.message,\n });\n }\n\n return respondDoc(doc as unknown as Record<string, unknown>);\n}\n\n/**\n * The submitted answers, or the refusal for a body that carries none.\n *\n * A malformed body and an absent one are the same mistake to a caller, so both\n * answer the way core did: a validation error naming `data`, rather than the\n * parser's own exception surfacing as a 500.\n */\nasync function readSubmittedData(\n req: Request,\n slug: string\n): Promise<Record<string, unknown>> {\n let parsed: unknown;\n try {\n parsed = await req.json();\n } catch {\n parsed = undefined;\n }\n const body = parsed as { data?: Record<string, unknown> } | undefined;\n\n if (!body?.data || typeof body.data !== \"object\") {\n throw NextlyError.validation({\n errors: [\n {\n path: \"data\",\n code: \"MISSING_FIELD\",\n message: \"Request body must contain a 'data' object.\",\n },\n ],\n logContext: { slug },\n });\n }\n return body.data;\n}\n\n/**\n * The refusal an unaccepted outcome owes the caller.\n *\n * One place, so the status a given ending produces is read off a single list\n * rather than inferred from a message. `accepted` never reaches here: it is the\n * one outcome with a body rather than an error, and the caller returns it.\n */\nfunction refusalFor(\n result: Awaited<ReturnType<typeof submitForm>>,\n slug: string\n): NextlyError {\n const entity = { entity: \"form\", slug };\n switch (result.outcome) {\n case \"no-such-form\":\n return NextlyError.notFound({\n message: NO_SUCH_FORM,\n logContext: entity,\n });\n\n // A state conflict, not a validation failure. `validation` fixes the\n // canonical `error.message` to \"Validation failed.\" and nests the real one\n // under `error.data.errors[0]`, so a client reading the documented envelope\n // would be handed the author's explanation and never show it.\n case \"closed\":\n return NextlyError.conflict({\n reason: \"state\",\n message: result.error ?? NO_SUCH_FORM,\n logContext: { ...entity, reason: \"closed\" },\n });\n\n case \"duplicate\":\n return NextlyError.conflict({\n reason: \"state\",\n message: result.error ?? \"You have already submitted this form.\",\n logContext: { ...entity, reason: \"duplicate\" },\n });\n\n // The codes come from the issue list, not from the message map beside it.\n // A client tells \"you left this blank\" from \"this is not an email\" by the\n // code, and reporting every failure as `INVALID` stops that working while\n // looking correct in a status-only test.\n case \"invalid\":\n return NextlyError.validation({\n errors: (result.validationIssues ?? []).map(issue => ({\n path: issue.path,\n code: issue.code,\n message: issue.message,\n })),\n logContext: { slug },\n });\n\n // A typed refusal is re-thrown as itself. The collection may refuse a write\n // for a reason its host chose, and those carry their own status and field\n // detail; answering all of them with one internal error tells a visitor the\n // server broke when their submission was in fact answered deliberately.\n // Anything untyped stays a 500, which is what an unexpected failure is.\n default:\n if (result.cause instanceof NextlyError) return result.cause;\n return NextlyError.internal({\n logContext: { ...entity, reason: \"submission-failed\" },\n });\n }\n}\n\nasync function acceptSubmission(\n req: Request,\n ctx: PluginRouteContext,\n config: ResolvedFormBuilderConfig,\n slug: string\n): Promise<Response> {\n const data = await readSubmittedData(req, slug);\n\n const result = await submitForm(\n {\n formSlug: slug,\n data,\n // The visitor's language, from `?locale=` on the submit URL — the same\n // place core's own routes read a locale on the wire — so a picked\n // redirect page answers with its URL in that language. A form on a\n // French page posts to `/api/forms/contact/submit?locale=fr` and is\n // sent to `/merci`; one that names no locale is answered in the\n // default, as before. An unconfigured code reads as the default rather\n // than refusing the submission: the visitor filled the form in, and a\n // wrong query string is not a reason to lose what they wrote.\n locale: new URL(req.url).searchParams.get(\"locale\") || undefined,\n // The request itself, so the write seam resolves the client under this\n // deployment's proxy-trust settings and judges this submission on the\n // same facts as one arriving through any other door.\n request: req,\n // The address for the audit column and the single-submission check,\n // resolved by the core rather than read off a header here, which would\n // record whatever the sender chose to claim and let one visitor pass as\n // many.\n metadata: {\n ipAddress: trustedClientIp(req) ?? undefined,\n userAgent: req.headers.get(\"user-agent\") ?? undefined,\n },\n access: PUBLIC,\n },\n {\n pluginContext: ctx,\n pluginConfig: configForInstall(config, ctx.self),\n }\n );\n\n if (result.outcome !== \"accepted\") throw refusalFor(result, slug);\n\n // One envelope for every accepted ending, including the two that are\n // deliberately indistinguishable from a refusal a bot could learn from: a\n // submission the honeypot flagged, and one the rate limiter refused.\n //\n // Both omit `submissionId`, because neither has a row a caller could then ask\n // about. That is a residual difference in the BODY, not in the status, and it\n // is the honest one: the alternatives are to invent an id that addresses\n // nothing, or to stop returning it to the real clients this endpoint has\n // always returned it to.\n return respondAction(\n result.successMessage ?? DEFAULT_SUCCESS_MESSAGE,\n {\n ...(result.submission ? { submissionId: result.submission.id } : {}),\n // Forwarded because this route is the only thing that can act on it. A\n // form configured to redirect on success resolves one here, and core's\n // endpoint dropped it, so the setting did nothing for any client posting\n // over HTTP. Additive, and present on a flagged submission too, so it\n // cannot be diffed against an accepted one.\n ...(result.redirect ? { redirect: result.redirect } : {}),\n },\n { status: 201 }\n );\n}\n\n/**\n * The three public form endpoints, at the addresses core used to serve.\n *\n * `public: true` on all three: a form is filled in by a visitor with no session\n * at all, which is the whole point of the feature.\n */\nexport function publicFormRoutes(\n config: ResolvedFormBuilderConfig\n): PluginRoute[] {\n return [\n {\n method: \"GET\",\n path: \"/forms\",\n mount: \"root\",\n public: true,\n // Collection documents, so their stored timestamps are presented in the\n // installation's timezone exactly as the built-in read did.\n formatTimestamps: true,\n handler: (req, ctx) => listPublishedForms(req, ctx, config),\n },\n {\n method: \"GET\",\n path: \"/forms/:slug\",\n mount: \"root\",\n public: true,\n formatTimestamps: true,\n handler: (_req, ctx) => readFormBySlug(ctx.params.slug, ctx, config),\n },\n {\n method: \"POST\",\n path: \"/forms/:slug/submit\",\n mount: \"root\",\n public: true,\n handler: (req, ctx) =>\n acceptSubmission(req, ctx, config, ctx.params.slug),\n },\n ];\n}\n","/**\n * Form Submission Handler\n *\n * Processes form submissions including validation, spam detection,\n * storage, and notification triggering.\n *\n * @module handlers/submit-form\n * @since 0.1.0\n */\n\nimport { isLocaleSelector } from \"@nextlyhq/plugin-sdk\";\nimport { formAvailability, NextlyError, NO_SUCH_FORM } from \"nextly\";\nimport type { PluginContext } from \"nextly\";\n\nimport { asFormDocument, asSubmissionDocument } from \"../document-shapes\";\nimport type {\n FormDocument,\n SubmissionDocument,\n ResolvedFormBuilderConfig,\n} from \"../types\";\nimport { normalizeFormSettings } from \"../utils/form-settings\";\nimport type { ValidationIssue } from \"../utils/generate-schema\";\nimport {\n applyRedirectPattern,\n documentReachability,\n parseRedirectReference,\n pickedDocumentField,\n type RedirectTargetDocument,\n type RedirectUrlPattern,\n} from \"../utils/redirect-target\";\n\nimport { asPluginSubmission, prepareSubmission } from \"./prepare-submission\";\nimport { checkSpam } from \"./spam-detection\";\n\n// ============================================================\n// Types\n// ============================================================\n\n/**\n * Options for submitting a form.\n */\nexport interface SubmitFormOptions {\n /** Form slug (URL-friendly identifier) */\n formSlug: string;\n\n /** Form submission data */\n data: Record<string, unknown>;\n\n /** Optional metadata about the submission */\n metadata?: {\n /** Submitter's IP address */\n ipAddress?: string;\n /** Submitter's user agent string */\n userAgent?: string;\n };\n\n /**\n * The HTTP request this submission arrived on.\n *\n * A route calling this helper is its own route, outside everything Nextly\n * pins a request around, so nothing else can supply it. Without it the write\n * reaches the submissions hooks looking like a seed or a job, and every rule\n * scoped to a visitor stands down: the rate limit above all.\n *\n * The request rather than an address, because the core resolves the client\n * from it under this deployment's proxy-trust settings. `metadata.ipAddress`\n * is a string the caller chose, which is the right shape for the audit column\n * it fills and the wrong shape for deciding who to throttle.\n */\n request?: Request;\n\n /**\n * Whose permissions the reads and the write are judged against.\n *\n * Defaults to `system`, so every existing caller behaves as before.\n */\n access?: SubmissionAccess;\n\n /**\n * The language the visitor submitted in.\n *\n * Decides which translation a picked redirect page is read in, and so which\n * URL the visitor is sent to: a page whose slug is `thanks` in English and\n * `merci` in French answered `/thanks` to everyone, because the target was\n * read with no locale and the default answered. The built-in route takes it\n * from `?locale=` on the submit URL, the same way core's own routes take a\n * locale on the wire; a host route passes whatever it resolved for the page\n * the form was on.\n *\n * A code that is not a configured locale reads as the default rather than\n * failing the submission, which the collection services decide. Absent means\n * the default, as it always has. The read wildcards (`all`, `*`) are not a\n * language a visitor can have submitted in and are treated as absent: a read\n * in \"every language\" answers a localized URL field with one value per\n * language, which no URL pattern can be filled from.\n */\n locale?: string;\n}\n\n/**\n * How this submission reaches the collections it touches.\n *\n * `system` is the default and is right for host code: the caller already\n * decided, and there is no visitor whose permissions could be consulted.\n *\n * `public` is for a route serving an anonymous visitor. It keeps the\n * collection's configured `access` rules in force, so a host that closed\n * submissions or restricted reading forms is obeyed rather than bypassed. The\n * former core endpoint ran this way, and elevating a public route to `system`\n * silently overrode that configuration.\n */\nexport type SubmissionAccess = { as: \"system\" } | { as: \"public\" };\n\n/**\n * Why a submission ended the way it did.\n *\n * `success` alone cannot carry this. An HTTP route has to answer 404 for a slug\n * nobody used, 409 for a form its author closed and 400 for data that did not\n * validate, and a boolean plus a human sentence cannot tell those apart without\n * matching on the sentence.\n *\n * `accepted` covers every ending a visitor is told succeeded, INCLUDING the two\n * that are deliberately indistinguishable from success: a submission the\n * honeypot flagged, and one the rate limiter refused. A caller must not try to\n * separate them, which is why they do not appear here as their own names.\n */\nexport type SubmitFormOutcome =\n | \"accepted\"\n | \"no-such-form\"\n | \"closed\"\n | \"invalid\"\n | \"duplicate\"\n | \"failed\";\n\n/**\n * Result of form submission.\n */\nexport interface SubmitFormResult {\n /** Whether the submission was successful */\n success: boolean;\n\n /**\n * Which ending this was, for a caller that has to map it to a status code.\n *\n * Required rather than optional, so a return path added later cannot omit it\n * and leave the HTTP route guessing from the message text.\n */\n outcome: SubmitFormOutcome;\n\n /** The created submission document (on success) */\n submission?: SubmissionDocument;\n\n /** Error message (on failure) */\n error?: string;\n\n /** Field-level validation errors */\n validationErrors?: Record<string, string>;\n\n /**\n * The same failures with their machine codes, for a caller building an HTTP\n * error. `validationErrors` above cannot carry one, so a route reading only\n * that has to invent a code and reports every blank answer as `INVALID`.\n */\n validationIssues?: ValidationIssue[];\n\n /** Redirect URL (if form configured for redirect on success) */\n redirect?: string;\n\n /**\n * The error that ended a `failed` submission, kept rather than flattened.\n *\n * A collection can refuse a write for reasons its host chose: a\n * `beforeValidate` hook throwing `NextlyError.validation`, a uniqueness\n * conflict. Those carry their own status and field detail, and reporting them\n * all as one internal error tells a visitor the server broke when in fact\n * their submission was answered deliberately.\n */\n cause?: unknown;\n\n /**\n * The toast the form's author wrote for a successful submission.\n *\n * Carried on the result rather than read from the form again by whoever\n * presents it: the HTTP route would otherwise have to fetch the form a second\n * time purely to learn a string this function already had in hand, and a\n * second read is a second chance to disagree about which form was submitted.\n *\n * Present on every `accepted` ending, including the ones a bot caused, so the\n * message cannot be used to tell an accepted submission from a flagged one.\n */\n successMessage?: string;\n}\n\n/**\n * Context for the submission handler.\n */\nexport interface SubmitFormContext {\n /** Plugin context with access to services */\n pluginContext: PluginContext;\n\n /** Resolved plugin configuration */\n pluginConfig: ResolvedFormBuilderConfig;\n}\n\n// ============================================================\n// Main Submission Handler\n// ============================================================\n\n/**\n * Process a form submission.\n *\n * This is the main entry point for handling form submissions.\n * It performs the following steps:\n *\n * 1. Fetch form configuration by slug\n * 2. Verify form status (must be \"published\")\n * 3. Transform and validate submission data\n * 4. Check for spam (honeypot, rate limiting)\n * 5. Create submission record in database\n * 6. Return result with redirect info if configured\n *\n * Note: Email notifications are triggered separately via collection\n * hooks in the submissions collection (see Phase 4.2).\n *\n * @param options - Submission options\n * @param context - Handler context with services\n * @returns Submission result\n *\n * @example\n * ```typescript\n * const result = await submitForm(\n * {\n * formSlug: 'contact-form',\n * data: { name: 'John', email: 'john@example.com', message: 'Hello!' },\n * metadata: { ipAddress: '127.0.0.1', userAgent: 'Mozilla/5.0...' },\n * },\n * { pluginContext, pluginConfig }\n * );\n *\n * if (result.success) {\n * console.log('Submission created:', result.submission?.id);\n * if (result.redirect) {\n * // Redirect user\n * }\n * } else {\n * console.error('Submission failed:', result.error);\n * }\n * ```\n */\nexport async function submitForm(\n options: SubmitFormOptions,\n context: SubmitFormContext\n): Promise<SubmitFormResult> {\n const { formSlug, data, metadata, request } = options;\n const access: SubmissionAccess = options.access ?? { as: \"system\" };\n const locale = visitorLocale(options.locale);\n const targetRead: TargetRead = { access, locale, request };\n const { pluginContext, pluginConfig } = context;\n const { collections } = pluginContext.services;\n const { logger } = pluginContext;\n\n try {\n // 1. Fetch form configuration\n const form = await fetchFormBySlug(\n formSlug,\n pluginConfig,\n pluginContext,\n access\n );\n\n if (!form) {\n logger.warn?.(\"Form submission attempted for non-existent form\", {\n formSlug,\n });\n return { success: false, outcome: \"no-such-form\", error: NO_SUCH_FORM };\n }\n\n // The destination is resolved from one place, because two endings ask for\n // it — the accepted submission and the throttled one disguised as accepted\n // — and two call sites naming the inputs separately is how one of them\n // comes to be answered in the wrong language while the other is right.\n const redirectFor = () =>\n resolveRedirectUrl(form, pluginConfig, pluginContext, targetRead);\n\n // 2. Check form status. The same reading the HTTP and Direct API paths do,\n // so what a visitor is told does not depend on which entry point their\n // client used.\n const availability = formAvailability(form);\n if (availability.kind !== \"open\") {\n logger.info?.(\"Form submission rejected - form not accepting\", {\n formSlug,\n status: form.status,\n availability: availability.kind,\n });\n // `absent` answers exactly as a slug nobody used does. Saying \"not\n // currently accepting submissions\" here would confirm the form exists,\n // which is the enumeration this whole reading exists to prevent — and it\n // would disagree with the REST and Direct API paths, which is how the\n // four came to give four different answers in the first place.\n return {\n success: false,\n outcome: availability.kind === \"closed\" ? \"closed\" : \"no-such-form\",\n error:\n availability.kind === \"closed\" ? availability.message : NO_SUCH_FORM,\n };\n }\n\n // 2b. Settings come through the one canonical reader — stored settings\n // may carry legacy keys from earlier builder versions.\n const settings = normalizeFormSettings(form.settings);\n\n // 2c. Single-submission forms: the same visitor (by IP) submits once.\n // Checked before spam so a repeat visitor gets the honest message\n // instead of an inexplicable success that stored nothing new. This is a\n // best-effort deterrent, not a guarantee: IPs are shared (NAT/proxies),\n // and the check-then-create pair is not atomic, so two truly concurrent\n // submissions can both pass — an airtight version needs a partial\n // unique index the portable field config cannot express yet.\n if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {\n // Without an identifier there is nothing to deduplicate on. Failing\n // closed would block every server-to-server integration, so the gate\n // stays open — loudly.\n logger.warn?.(\n \"Single-submission form received a submission without an IP — the restriction cannot apply\",\n { formSlug }\n );\n }\n if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {\n const existing = await collections.count(\n pluginConfig.formSubmissionOverrides.slug,\n {\n where: {\n form: { equals: form.id },\n ipAddress: { equals: metadata.ipAddress },\n status: { not_equals: \"spam\" },\n },\n },\n // Deliberately elevated, unlike the write below. This counts rows the\n // visitor is not entitled to see in order to refuse their second\n // submission; judged as the public it would count zero on any install\n // that hides submissions and the restriction would never apply.\n { as: \"system\" }\n );\n if (existing > 0) {\n logger.info?.(\"Repeat submission rejected (single-submission form)\", {\n formSlug,\n ipAddress: metadata.ipAddress,\n });\n return {\n success: false,\n outcome: \"duplicate\",\n error: \"You have already submitted this form.\",\n };\n }\n }\n\n // 3. Spam detection — BEFORE validation, so a bot that trips the trap\n // while also failing validation still receives the same fake success as\n // any other bot instead of a distinguishable validation error.\n // Honeypot fields are by definition NOT declared form fields, so the\n // probe is the raw payload minus the declared schema — a declared field\n // (e.g. a real \"website\" input) must never trip the trap.\n const declaredFieldNames = new Set(form.fields.map(field => field.name));\n const undeclaredData = Object.fromEntries(\n Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))\n );\n\n // The honeypot only, and only to decide whether to validate. It is a pure\n // read of the payload, so running it here and again at the write seam\n // costs nothing and cannot disagree. The RATE LIMIT is not asked here: it\n // counts, and a check with a side effect run twice counts one submission\n // twice. The seam owns that one, and owns the verdict written to the row.\n const spamResult = await checkSpam({\n data: undeclaredData,\n formSlug,\n config: {\n // Per-form overrides win where set; blank inherits the plugin config.\n honeypot:\n settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,\n },\n });\n\n const isContentSpam = spamResult.isSpam;\n\n // 4. Transform + validate. Content spam skips validation entirely — it\n // is stored for review exactly as the bot shaped it (declared fields\n // only, sanitized), and requiring validity would drop the evidence.\n if (isContentSpam) {\n logger.info?.(\"Spam submission detected — storing flagged\", {\n formSlug,\n reason: spamResult.reason,\n ipAddress: metadata?.ipAddress,\n });\n }\n\n const prepared = prepareSubmission({\n data,\n fields: form.fields,\n validate: !isContentSpam,\n });\n\n if (prepared.validationErrors) {\n logger.debug?.(\"Form validation failed\", {\n formSlug,\n errors: prepared.validationErrors,\n });\n return {\n success: false,\n outcome: \"invalid\",\n error: \"Validation failed\",\n validationErrors: prepared.validationErrors,\n validationIssues: prepared.validationIssues,\n };\n }\n\n const storedData = prepared.data;\n\n // 5. Create submission record. Content spam (honeypot/recaptcha) is\n // stored FLAGGED, never silently dropped: a false positive stays\n // reviewable in the Spam view and recoverable via \"Not spam\".\n const submissionData = {\n form: form.id,\n data: storedData,\n status: isContentSpam ? (\"spam\" as const) : (\"new\" as const),\n spamReason: isContentSpam ? (spamResult.reason ?? null) : null,\n ipAddress: metadata?.ipAddress || null,\n userAgent: metadata?.userAgent || null,\n submittedAt: new Date(),\n };\n\n // Marked as the plugin's own write for the length of the call, so the\n // write-seam hook knows a flagged row is evidence this handler chose to\n // keep rather than a caller asking for validation to be skipped.\n // Marked as this plugin's own write, so the write seam knows a flagged row\n // is evidence this handler chose to keep rather than a caller asking for\n // validation to be skipped. The form goes with it so the seam does not read\n // it a second time.\n let submission: Awaited<ReturnType<typeof collections.createEntry>>;\n try {\n submission = await collections.createEntry(\n pluginConfig.formSubmissionOverrides.slug,\n asPluginSubmission(submissionData, {\n keepAsEvidence: isContentSpam,\n form: { id: form.id, fields: form.fields },\n }),\n // Public form submission — create as system. No ambient user; an\n // empty context already resolves to system, but be explicit.\n //\n // The request travels with it so the seam judges this submission on the\n // same facts as one arriving through a Nextly route. Omitted by a caller\n // that has none, and then the seam correctly reads the write as\n // server-side work.\n //\n // `access` rather than a fixed `system`, so a route serving an anonymous\n // visitor is still judged against the collection's own create rule. A\n // host that closed public submissions configured that on the\n // collection, and a write elevated past it reports a 201 for a\n // submission they had refused.\n { ...access, ...(request ? { request } : {}) }\n );\n } catch (error) {\n // The seam refuses a submission over the limit, and refuses it the same\n // way at every door. This answers its own caller with a success anyway: a\n // browser is on the other end of it, and a bot told it was limited learns\n // the rate to sit under.\n //\n // `POST /api/forms/:slug/submit` reaches this. The route is contributed\n // by this plugin and calls this function, so the refusal a visitor sees\n // is decided here rather than being reconstructed as a 429 by an endpoint\n // that never consulted the seam.\n //\n // Nothing is stored. A limiter that wrote a row per refusal would hand an\n // attacker a way to fill the database with the very volume it exists to\n // refuse.\n if (NextlyError.isRateLimited(error)) {\n logger.info?.(\"Form submission refused (rate limit)\", { formSlug });\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n // The same destination an accepted submission gets. Returning early\n // without it left a throttled visitor on the form instead of the\n // page its author sends people to, and made the one response this\n // whole branch exists to disguise the only accepted one carrying no\n // redirect.\n redirect: await redirectFor(),\n };\n }\n throw error;\n }\n\n logger.info?.(\"Form submission created successfully\", {\n formSlug,\n submissionId: submission.item.id,\n flaggedAsSpam: isContentSpam,\n });\n\n // A post-commit hook cannot un-save the submission, so a failure there is\n // logged against the row rather than turned into a failed submission: the\n // notification email is the one that fails this way, and telling the\n // visitor their submission did not go through would have them send it\n // again.\n if (submission.warnings) {\n logger.warn?.(\"Form submission side effects failed\", {\n formSlug,\n submissionId: submission.item.id,\n warnings: submission.warnings,\n });\n }\n\n // Email notifications are sent via the afterCreate hook registered in\n // plugin.ts init(), so they fire for all submission paths (HTTP + direct).\n // The hook itself skips spam-flagged rows.\n\n // 6. Determine redirect URL. Spam gets the same success shape as a real\n // submission (minus the stored row reference) so bots can't diff the two.\n const redirect = await redirectFor();\n\n if (isContentSpam) {\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n redirect,\n };\n }\n\n return {\n success: true,\n outcome: \"accepted\",\n successMessage: settings.successMessage,\n // The created ROW, not the envelope around it. Through the shared\n // conversion, which is where the unchecked step lives and says so; the\n // integration test reads `submission.id` off the result because nothing\n // at compile time will.\n submission: asSubmissionDocument(submission.item),\n redirect,\n };\n } catch (error) {\n logger.error?.(\"Form submission error\", {\n formSlug,\n error: error instanceof Error ? error.message : String(error),\n });\n\n return {\n success: false,\n outcome: \"failed\",\n error: \"An error occurred processing your submission. Please try again.\",\n cause: error,\n };\n }\n}\n\n// ============================================================\n// Helper Functions\n// ============================================================\n\n/**\n * Fetch a form by its slug via a service-level `where` query.\n *\n * @param slug - Form slug\n * @param pluginConfig - Plugin configuration\n * @param pluginContext - Plugin context\n * @returns Form document or null if not found\n */\nexport async function fetchFormBySlug(\n slug: string,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n access: SubmissionAccess = { as: \"system\" }\n): Promise<FormDocument | null> {\n const { collections } = pluginContext.services;\n\n // D56: resolve the form by slug via a service-level `where` query instead of\n // fetching every form and filtering client-side.\n //\n // Nothing is caught here. A read that THREW did not establish that the form\n // is absent, and answering `null` made a database outage or a broken schema\n // indistinguishable from a slug nobody has ever used: the caller was told\n // \"no such form\", which is a 404 a client may cache and a person may act on\n // by deleting the link. A failure now propagates and is answered as the\n // server error it is.\n const result = await collections.listEntries(\n pluginConfig.formOverrides.slug,\n { where: { slug: { equals: slug } }, pagination: { limit: 1 } },\n access\n );\n\n const form = result.data?.[0];\n return form ? asFormDocument(form) : null;\n}\n\n/**\n * Where a successful submission sends the visitor, or undefined for nowhere.\n *\n * A destination is either TYPED as a URL or PICKED as a document, and the two\n * are answered differently: a typed URL is returned verbatim, since it may\n * legitimately point off-site, while a picked document has to be read and put\n * through its collection's configured URL pattern.\n *\n * Two settings hold a picked document — `redirectPage` under the\n * \"Redirect to Page\" option, and `redirectRelation` under the URL option —\n * and both resolve through the same path, because they ask the same question.\n *\n * Every way of failing to produce a URL is logged. A form whose destination\n * cannot be built behaves exactly like one configured to stay put, so without\n * a line in the log the two are indistinguishable from the outside.\n */\nasync function resolveRedirectUrl(\n form: FormDocument,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<string | undefined> {\n const settings = form.settings;\n if (!settings) return undefined;\n\n // A typed URL is returned verbatim: it is the whole point of that option,\n // and it may legitimately point off-site.\n if (settings.confirmationType === \"redirect\" && settings.redirectUrl) {\n return settings.redirectUrl;\n }\n\n // Otherwise whichever key names a document, read through the SAME function\n // the save-time rule uses. Two readers here would let this path redirect to\n // a document the rule never inspected — which it did: the rule matched only\n // `relationship`, so a `redirect`-with-relation form was resolved here and\n // guarded nowhere.\n const field = pickedDocumentField(settings as Record<string, unknown>);\n if (!field) return undefined;\n\n return urlForPickedDocument(\n (settings as Record<string, unknown>)[field],\n form,\n pluginConfig,\n pluginContext,\n read\n );\n}\n\n/**\n * The URL for a picked document, or undefined with a reason in the log.\n *\n * Every branch reports. A form whose destination cannot be built behaves\n * exactly like one configured to stay put, so without a line here the two are\n * indistinguishable from outside — which is how a redirect that never fired\n * went unnoticed.\n */\nasync function urlForPickedDocument(\n stored: unknown,\n form: FormDocument,\n pluginConfig: ResolvedFormBuilderConfig,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<string | undefined> {\n const { logger } = pluginContext;\n const patterns = pluginConfig.redirectRelationships;\n const reference = parseRedirectReference(stored, Object.keys(patterns));\n\n if (!reference) {\n logger.warn?.(\"Form redirects to a page, but names no readable document\", {\n form: form.slug,\n });\n return undefined;\n }\n\n const pattern = patterns[reference.collection];\n if (!pattern) {\n logger.warn?.(\n \"Form redirects to a collection that is not in redirectRelationships\",\n { form: form.slug, collection: reference.collection }\n );\n return undefined;\n }\n\n const target = await readTarget(reference, form, pluginContext, read);\n if (!target) return undefined;\n\n // Reachability is re-decided HERE, not inherited from the save. Nothing runs\n // a forms hook when the target itself changes, so a page that was published\n // when the form was saved can be unpublished afterwards and the save-time\n // rule never sees it. Sending a visitor there produces exactly the \"page not\n // found\" that rule exists to prevent, so this degrades to no redirect — the\n // submission is still stored and still succeeds.\n // The target collection's own `localized` setting, resolved at plugin init.\n // Absent means it could not be read, which reports as undecided rather than\n // as \"not localized\" — dropping a redirect on that guess would strand a\n // destination a translation is still serving.\n if (\n documentReachability(\n target,\n pluginConfig.redirectTargetLocalization[reference.collection]\n ) === \"unreachable\"\n ) {\n logger.warn?.(\"Form redirects to a page that has never been published\", {\n form: form.slug,\n collection: reference.collection,\n target: reference.id,\n });\n return undefined;\n }\n\n return buildUrl(pattern, target, reference.collection, form, pluginContext);\n}\n\n/**\n * The pattern applied, or undefined with the reason logged.\n *\n * Separate because a configured pattern may be a FUNCTION — host code, which\n * can throw. Letting that escape would reach `submitForm`'s outer catch AFTER\n * the submission row exists, reporting a failure the caller may retry into a\n * duplicate of a submission that was already saved.\n */\nfunction buildUrl(\n pattern: RedirectUrlPattern,\n target: RedirectTargetDocument,\n collection: string,\n form: FormDocument,\n pluginContext: PluginContext\n): string | undefined {\n const { logger } = pluginContext;\n try {\n const url = applyRedirectPattern(pattern, target);\n if (!url) {\n logger.warn?.(\n \"Form redirect pattern could not be filled from the target document\",\n { form: form.slug, collection }\n );\n }\n return url;\n } catch (error) {\n logger.warn?.(\"Form redirect pattern threw while building a URL\", {\n form: form.slug,\n collection,\n error: error instanceof Error ? error.message : String(error),\n });\n return undefined;\n }\n}\n\n/** The target row, or undefined — a deleted target and a failed read differ. */\n/**\n * How the redirect target is read: as whom, in which language, and on which\n * request.\n *\n * As whom is the submission's own `access`, because the destination is a page\n * the VISITOR is about to be sent to, and the visitor's view of it is the one\n * that decides whether it is there. A public submission reads the target as a\n * public caller, which is scoped to published rows and — the part the locale\n * makes matter — to published TRANSLATIONS: a companion row still in draft is\n * withheld and the field falls back to the published default. Read as the\n * system instead, the trusted read admits every draft, and a French slug that\n * nobody has published yet becomes the URL a French visitor is sent to.\n *\n * A host that elevated the submission to `system` keeps the trusted read it\n * asked for, as before this carried a language.\n *\n * The request travels for the same reason the access does: a read that is\n * the visitor's should look like the visitor's to every hook on the target\n * collection. One that selects content by a tenant header, or applies only\n * to a browser, reads `ctx.req.http` — and a read carrying no request runs\n * those hooks as background work and builds a URL the visitor was never\n * meant to get.\n */\ninterface TargetRead {\n access: SubmissionAccess;\n locale: string | undefined;\n request: Request | undefined;\n}\n\n/**\n * The language a visitor submitted in, or nothing.\n *\n * A selector is not one: it asks the collection services for every\n * translation at once, which answers a localized URL field with one value per\n * language — a shape no URL pattern can be filled from — and the plugin\n * boundary refuses it outright, which after the submission is stored degrades\n * to no redirect. Neither is a language a form was filled in, so a selector\n * reads as no language named. Which spellings are selectors is the core's\n * question, asked of the core, so this cannot forward one the boundary has\n * since learned to refuse.\n */\nfunction visitorLocale(locale: string | undefined): string | undefined {\n if (locale === undefined || isLocaleSelector(locale)) return undefined;\n return locale;\n}\n\nasync function readTarget(\n reference: { collection: string; id: string },\n form: FormDocument,\n pluginContext: PluginContext,\n read: TargetRead\n): Promise<RedirectTargetDocument | undefined> {\n const { logger } = pluginContext;\n const context = {\n form: form.slug,\n collection: reference.collection,\n id: reference.id,\n };\n\n let row: unknown;\n try {\n // In the visitor's language, so a localized URL field answers with the\n // slug that page has THERE, and as the visitor, so it answers only with a\n // translation they can reach (see {@link TargetRead}). The fallback chain\n // stays in force: a page with no published translation for this locale\n // still has a URL, in the language the site falls back to, and sending\n // the visitor there beats sending them nowhere.\n row = await pluginContext.services.collections.findEntryById(\n reference.collection,\n reference.id,\n { ...read.access, locale: read.locale, request: read.request }\n );\n } catch (error) {\n // The submission already succeeded, so this degrades to \"no redirect\"\n // rather than failing the request — reported, because a broken read and a\n // deleted target are different problems with the same symptom.\n logger.warn?.(\"Form redirect target could not be read\", {\n ...context,\n error: error instanceof Error ? error.message : String(error),\n });\n return undefined;\n }\n\n if (!row || typeof row !== \"object\") {\n logger.warn?.(\"Form redirect target no longer exists\", context);\n return undefined;\n }\n return row as RedirectTargetDocument;\n}\n\n// ============================================================\n// Validation-Only Function\n// ============================================================\n\n/**\n * Validate form data without creating a submission.\n *\n * Useful for real-time validation in the frontend.\n *\n * @param formSlug - Form slug\n * @param data - Form data to validate\n * @param context - Handler context\n * @returns Validation result\n *\n * @example\n * ```typescript\n * const result = await validateSubmission(\n * 'contact-form',\n * { name: 'John', email: 'invalid-email' },\n * { pluginContext, pluginConfig }\n * );\n *\n * if (!result.valid) {\n * console.log('Validation errors:', result.errors);\n * }\n * ```\n */\nexport async function validateSubmission(\n formSlug: string,\n data: Record<string, unknown>,\n context: SubmitFormContext\n): Promise<{ valid: boolean; errors?: Record<string, string> }> {\n const { pluginContext, pluginConfig } = context;\n\n // Fetch form\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n\n if (!form) {\n return { valid: false, errors: { _form: \"Form not found\" } };\n }\n\n // The same rule storage applies, asked rather than restated. Restating it\n // meant this answered on the transformed but unsanitized value while the\n // write seam answers on the sanitized one, so a preflight check could call a\n // submission valid that storage then refused.\n const prepared = prepareSubmission({\n data,\n fields: form.fields,\n validate: true,\n });\n\n if (!prepared.validationErrors) {\n return { valid: true };\n }\n\n return {\n valid: false,\n errors: prepared.validationErrors,\n };\n}\n\n// ============================================================\n// Form Status Check\n// ============================================================\n\n/**\n * Check if a form is accepting submissions.\n *\n * @param formSlug - Form slug\n * @param context - Handler context\n * @returns Whether the form accepts submissions\n */\nexport async function isFormAcceptingSubmissions(\n formSlug: string,\n context: SubmitFormContext\n): Promise<boolean> {\n const { pluginContext, pluginConfig } = context;\n\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n\n if (!form) {\n return false;\n }\n\n return form.status === \"published\";\n}\n\n// ============================================================\n// Bulk Operations (Admin)\n// ============================================================\n\n/**\n * Get submission statistics for a form.\n *\n * Note: This function uses client-side filtering which works for\n * small-medium submission counts. For large-scale deployments,\n * consider using direct database queries with COUNT aggregation.\n *\n * @param formSlug - Form slug\n * @param context - Handler context\n * @returns Submission statistics\n */\nexport async function getFormSubmissionStats(\n formSlug: string,\n context: SubmitFormContext\n): Promise<{\n total: number;\n new: number;\n read: number;\n archived: number;\n} | null> {\n const { pluginContext, pluginConfig } = context;\n const { collections } = pluginContext.services;\n\n // Get form ID\n const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);\n if (!form) {\n return null;\n }\n\n try {\n const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;\n const sys = { as: \"system\" } as const;\n const base = { form: { equals: form.id } };\n\n // D56: count per status server-side instead of listing every\n // submission and counting client-side. Stats run as system (plugin-owned\n // aggregate over submissions for this form).\n const [total, newCount, read, archived] = await Promise.all([\n collections.count(submissionsSlug, { where: base }, sys),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"new\" } } },\n sys\n ),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"read\" } } },\n sys\n ),\n collections.count(\n submissionsSlug,\n { where: { ...base, status: { equals: \"archived\" } } },\n sys\n ),\n ]);\n\n return { total, new: newCount, read, archived };\n } catch {\n return null;\n }\n}\n","/**\n * Form Configuration Validator\n *\n * Provides comprehensive validation for form configurations including:\n * - Slug validation (format, reserved names)\n * - Field name validation (format, duplicates)\n * - Field-specific validation (select/radio options, min/max constraints)\n * - Conditional logic validation\n * - Nested field validation\n *\n * @module config/validate-form\n * @since 1.0.0\n *\n * @example\n * ```typescript\n * import { validateFormConfig } from '@nextly/plugin-form-builder';\n *\n * const result = validateFormConfig(config);\n * if (!result.valid) {\n * console.error('Validation errors:', result.errors);\n * }\n * ```\n */\n\nimport { isPluginFieldTypeOnSurface } from \"nextly\";\n\nimport { BUILT_IN_FORM_FIELD_TYPES } from \"../types\";\nimport type { FormConfig, FormField, FormFieldType } from \"../types\";\nimport { parseRedirectReference } from \"../utils/redirect-reference\";\n\nimport { warnIfWebhooksConfigured } from \"./removed-options\";\n\n// ============================================================\n// Validation Error Types\n// ============================================================\n\n/**\n * Error codes for form validation failures.\n * Used for programmatic error handling.\n */\nexport type FormValidationErrorCode =\n // Slug errors\n | \"SLUG_REQUIRED\"\n | \"SLUG_INVALID_TYPE\"\n | \"SLUG_TOO_SHORT\"\n | \"SLUG_TOO_LONG\"\n | \"SLUG_INVALID_FORMAT\"\n | \"SLUG_RESERVED\"\n // Field errors\n | \"FIELDS_REQUIRED\"\n | \"FIELDS_INVALID_TYPE\"\n | \"FIELDS_EMPTY\"\n | \"FIELD_NAME_REQUIRED\"\n | \"FIELD_NAME_INVALID_FORMAT\"\n | \"FIELD_NAME_DUPLICATE\"\n | \"FIELD_TYPE_REQUIRED\"\n | \"FIELD_TYPE_INVALID\"\n | \"FIELD_LABEL_REQUIRED\"\n // Field-specific errors\n | \"OPTIONS_REQUIRED\"\n | \"OPTIONS_EMPTY\"\n | \"MIN_GREATER_THAN_MAX\"\n | \"MIN_LENGTH_GREATER_THAN_MAX\"\n | \"FILE_MAX_SIZE_INVALID\"\n // Conditional logic errors\n | \"CONDITIONS_REQUIRED\"\n | \"CONDITION_FIELD_REQUIRED\"\n | \"CONDITION_COMPARISON_REQUIRED\"\n // Settings errors\n | \"SETTINGS_INVALID_TYPE\"\n | \"REDIRECT_URL_REQUIRED\"\n | \"REDIRECT_PAGE_REQUIRED\"\n // Access errors\n | \"ACCESS_INVALID_TYPE\"\n | \"ACCESS_FUNCTION_INVALID\";\n\n/**\n * A single validation error with path and context.\n *\n * @example\n * ```typescript\n * const error: FormValidationError = {\n * path: 'fields.email.validation',\n * message: \"Min length cannot be greater than max length\",\n * code: 'MIN_LENGTH_GREATER_THAN_MAX',\n * };\n * ```\n */\nexport interface FormValidationError {\n /**\n * Dot-notation path to the invalid property.\n * @example 'slug', 'fields.0.name', 'fields.email.options'\n */\n path: string;\n\n /**\n * Human-readable error message.\n */\n message: string;\n\n /**\n * Machine-readable error code for programmatic handling.\n */\n code: FormValidationErrorCode;\n}\n\n/**\n * Result of form config validation.\n */\nexport interface FormValidationResult {\n /**\n * Whether the configuration is valid.\n */\n valid: boolean;\n\n /**\n * Array of validation errors (empty if valid).\n */\n errors: FormValidationError[];\n}\n\n// ============================================================\n// Reserved Names\n// ============================================================\n\n/**\n * Reserved form slugs that cannot be used.\n * These are used by the system or have special meaning.\n */\nexport const RESERVED_FORM_SLUGS = [\n // API routes\n \"api\",\n \"submit\",\n \"submissions\",\n \"export\",\n // System slugs\n \"admin\",\n \"new\",\n \"create\",\n \"edit\",\n \"delete\",\n] as const;\n\n/**\n * Set for O(1) lookup of reserved slugs.\n */\nconst RESERVED_SLUGS_SET = new Set<string>(RESERVED_FORM_SLUGS);\n\n// ============================================================\n// Validation Patterns\n// ============================================================\n\n/**\n * Regex pattern for valid form slugs.\n * Must start with a lowercase letter, contain only lowercase letters,\n * numbers, hyphens, and underscores. Length: 1-50 chars.\n * Allows hyphens for URL-friendly slugs (e.g., 'contact-form').\n */\nconst SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;\n\n/**\n * Regex pattern for valid field names.\n * Must start with a letter (upper or lower), contain only letters,\n * numbers, and underscores. Supports camelCase (e.g., 'firstName').\n */\nconst FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;\n\n/**\n * All valid built-in form field types (plugin types validate via the registry).\n */\nconst VALID_FIELD_TYPES: readonly FormFieldType[] = BUILT_IN_FORM_FIELD_TYPES;\n\nconst VALID_FIELD_TYPES_SET = new Set<string>(VALID_FIELD_TYPES);\n\n// ============================================================\n// Validation Helper Functions\n// ============================================================\n\n/**\n * Checks if a string is a reserved form slug.\n */\nfunction isReservedSlug(slug: string): boolean {\n return RESERVED_SLUGS_SET.has(slug.toLowerCase());\n}\n\n/**\n * Validates a form slug.\n */\nfunction validateSlug(slug: unknown, errors: FormValidationError[]): void {\n const path = \"slug\";\n\n if (!slug) {\n errors.push({\n path,\n message: \"Form slug is required\",\n code: \"SLUG_REQUIRED\",\n });\n return;\n }\n\n if (typeof slug !== \"string\") {\n errors.push({\n path,\n message: \"Form slug must be a string\",\n code: \"SLUG_INVALID_TYPE\",\n });\n return;\n }\n\n if (slug.length < 1) {\n errors.push({\n path,\n message: \"Form slug must be at least 1 character\",\n code: \"SLUG_TOO_SHORT\",\n });\n }\n\n if (slug.length > 50) {\n errors.push({\n path,\n message: \"Form slug must be at most 50 characters\",\n code: \"SLUG_TOO_LONG\",\n });\n }\n\n if (!SLUG_PATTERN.test(slug)) {\n errors.push({\n path,\n message:\n \"Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores\",\n code: \"SLUG_INVALID_FORMAT\",\n });\n }\n\n if (isReservedSlug(slug)) {\n errors.push({\n path,\n message: `Form slug '${slug}' is reserved and cannot be used`,\n code: \"SLUG_RESERVED\",\n });\n }\n}\n\n/**\n * Validates a single field name.\n */\nfunction validateFieldName(\n name: unknown,\n path: string,\n errors: FormValidationError[],\n seenNames: Set<string>\n): void {\n if (!name) {\n errors.push({\n path: `${path}.name`,\n message: \"Field name is required\",\n code: \"FIELD_NAME_REQUIRED\",\n });\n return;\n }\n\n if (typeof name !== \"string\") {\n errors.push({\n path: `${path}.name`,\n message: \"Field name must be a string\",\n code: \"FIELD_NAME_REQUIRED\",\n });\n return;\n }\n\n if (!FIELD_NAME_PATTERN.test(name)) {\n errors.push({\n path: `${path}.name`,\n message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,\n code: \"FIELD_NAME_INVALID_FORMAT\",\n });\n }\n\n // Check for duplicates at this level\n const nameLower = name.toLowerCase();\n if (seenNames.has(nameLower)) {\n errors.push({\n path: `${path}.name`,\n message: `Duplicate field name '${name}'`,\n code: \"FIELD_NAME_DUPLICATE\",\n });\n } else {\n seenNames.add(nameLower);\n }\n}\n\n/**\n * Validates a single field label.\n */\nfunction validateFieldLabel(\n label: unknown,\n path: string,\n errors: FormValidationError[]\n): void {\n if (!label || (typeof label === \"string\" && label.trim() === \"\")) {\n errors.push({\n path: `${path}.label`,\n message: \"Field label is required\",\n code: \"FIELD_LABEL_REQUIRED\",\n });\n }\n}\n\n/**\n * Validates select/radio/checkbox-group field options.\n */\nfunction validateOptions(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n // Type assertion for fields with options\n const fieldWithOptions = field as {\n options?: Array<{ label: string; value: string }>;\n };\n const options = fieldWithOptions.options;\n\n if (!options) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field must have an 'options' array`,\n code: \"OPTIONS_REQUIRED\",\n });\n return;\n }\n\n if (!Array.isArray(options)) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field 'options' must be an array`,\n code: \"OPTIONS_REQUIRED\",\n });\n return;\n }\n\n if (options.length === 0) {\n errors.push({\n path: `${path}.options`,\n message: `${field.type} field must have at least one option`,\n code: \"OPTIONS_EMPTY\",\n });\n }\n}\n\n/**\n * Validates conditional logic configuration.\n */\nfunction validateConditionalLogic(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n const { conditionalLogic } = field;\n\n if (!conditionalLogic || !conditionalLogic.enabled) {\n return;\n }\n\n if (\n !conditionalLogic.conditions ||\n conditionalLogic.conditions.length === 0\n ) {\n errors.push({\n path: `${path}.conditionalLogic.conditions`,\n message: \"Conditional logic must have at least one condition\",\n code: \"CONDITIONS_REQUIRED\",\n });\n return;\n }\n\n conditionalLogic.conditions.forEach((condition, index) => {\n const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;\n\n if (!condition.field) {\n errors.push({\n path: `${conditionPath}.field`,\n message: \"Condition must specify a field name\",\n code: \"CONDITION_FIELD_REQUIRED\",\n });\n }\n\n if (!condition.comparison) {\n errors.push({\n path: `${conditionPath}.comparison`,\n message: \"Condition must specify a comparison operator\",\n code: \"CONDITION_COMPARISON_REQUIRED\",\n });\n }\n });\n}\n\n/**\n * Validates field based on its type.\n */\nfunction validateFieldByType(\n field: FormField,\n path: string,\n errors: FormValidationError[]\n): void {\n switch (field.type) {\n case \"select\":\n case \"radio\":\n validateOptions(field, path, errors);\n break;\n\n case \"number\": {\n const numberField = field;\n if (numberField.validation) {\n const { min, max } = numberField.validation;\n if (min !== undefined && max !== undefined && min > max) {\n errors.push({\n path: `${path}.validation`,\n message: \"Min value cannot be greater than max value\",\n code: \"MIN_GREATER_THAN_MAX\",\n });\n }\n }\n break;\n }\n\n case \"text\":\n case \"textarea\": {\n const textField = field;\n if (textField.validation) {\n const { minLength, maxLength } = textField.validation;\n if (\n minLength !== undefined &&\n maxLength !== undefined &&\n minLength > maxLength\n ) {\n errors.push({\n path: `${path}.validation`,\n message: \"Min length cannot be greater than max length\",\n code: \"MIN_LENGTH_GREATER_THAN_MAX\",\n });\n }\n }\n break;\n }\n\n case \"file\": {\n const fileField = field;\n if (fileField.maxFileSize !== undefined && fileField.maxFileSize <= 0) {\n errors.push({\n path: `${path}.maxFileSize`,\n message: \"File maxFileSize must be a positive number\",\n code: \"FILE_MAX_SIZE_INVALID\",\n });\n }\n break;\n }\n }\n}\n\n/**\n * Validates a single field configuration.\n */\nfunction validateField(\n field: unknown,\n path: string,\n errors: FormValidationError[],\n seenNames: Set<string>\n): void {\n if (!field || typeof field !== \"object\") {\n return;\n }\n\n const f = field as Record<string, unknown>;\n\n // Validate field type\n const fieldType = f.type;\n if (!fieldType) {\n errors.push({\n path: `${path}.type`,\n message: \"Field type is required\",\n code: \"FIELD_TYPE_REQUIRED\",\n });\n return;\n }\n\n // A plugin may contribute a field type that opted into the forms surface; it\n // is as valid here as a built-in. Built-ins keep the fast allowlist; other\n // strings fall through to the registry, surface-gated.\n const isValidType =\n typeof fieldType === \"string\" &&\n (VALID_FIELD_TYPES_SET.has(fieldType) ||\n isPluginFieldTypeOnSurface(fieldType, \"forms\"));\n\n if (!isValidType) {\n let fieldTypeRepr: string;\n if (typeof fieldType === \"object\" && fieldType !== null) {\n fieldTypeRepr = JSON.stringify(fieldType);\n } else {\n // eslint-disable-next-line @typescript-eslint/no-base-to-string -- fieldType narrowed to primitive above; rule doesn't follow control flow on unknown\n fieldTypeRepr = String(fieldType);\n }\n errors.push({\n path: `${path}.type`,\n message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(\", \")}`,\n code: \"FIELD_TYPE_INVALID\",\n });\n return;\n }\n\n validateFieldName(f.name, path, errors, seenNames);\n\n // All fields except hidden require a label\n if (fieldType !== \"hidden\") {\n validateFieldLabel(f.label, path, errors);\n }\n\n // Type-specific validation\n validateFieldByType(field as FormField, path, errors);\n\n // Validate conditional logic if present\n if (f.conditionalLogic) {\n validateConditionalLogic(field as FormField, path, errors);\n }\n}\n\n/**\n * Validates an array of field configurations.\n */\nfunction validateFieldsArray(\n fields: unknown[],\n basePath: string,\n errors: FormValidationError[]\n): void {\n // Track field names for duplicate detection\n const seenNames = new Set<string>();\n\n fields.forEach((field, index) => {\n const fieldPath = `${basePath}[${index}]`;\n validateField(field, fieldPath, errors, seenNames);\n });\n}\n\n/**\n * Validates the top-level fields array.\n */\nfunction validateFields(fields: unknown, errors: FormValidationError[]): void {\n const path = \"fields\";\n\n if (!fields) {\n errors.push({\n path,\n message: \"Form must have at least one field\",\n code: \"FIELDS_REQUIRED\",\n });\n return;\n }\n\n if (!Array.isArray(fields)) {\n errors.push({\n path,\n message: \"Form fields must be an array\",\n code: \"FIELDS_INVALID_TYPE\",\n });\n return;\n }\n\n if (fields.length === 0) {\n errors.push({\n path,\n message: \"Form must have at least one field\",\n code: \"FIELDS_EMPTY\",\n });\n return;\n }\n\n validateFieldsArray(fields, path, errors);\n}\n\n/**\n * Validates form settings.\n */\nfunction validateSettings(\n settings: unknown,\n errors: FormValidationError[]\n): void {\n if (!settings) {\n return; // Settings are optional\n }\n\n if (typeof settings !== \"object\" || Array.isArray(settings)) {\n errors.push({\n path: \"settings\",\n message: \"Form settings must be an object\",\n code: \"SETTINGS_INVALID_TYPE\",\n });\n return;\n }\n\n const s = settings as Record<string, unknown>;\n\n // If confirmationType is \"redirect\", redirectUrl or redirectRelation is required\n if (s.confirmationType === \"redirect\") {\n if (!s.redirectUrl && !s.redirectRelation) {\n errors.push({\n path: \"settings.redirectUrl\",\n message:\n \"Redirect URL or redirect relation is required when confirmation type is 'redirect'\",\n code: \"REDIRECT_URL_REQUIRED\",\n });\n }\n }\n\n // A form set to redirect to a page needs the page. Without this the form\n // saves, the admin shows it as redirecting, and every submission finishes\n // with nowhere to go — the destination is missing at submit time, when\n // there is no one to tell.\n //\n // The SHAPE, not truthiness. `{}` and `{ relationTo: \"pages\" }` are truthy\n // and name no document, so a presence test admits precisely the values that\n // resolve to nothing. `null` for the collection list because this entry\n // point does not have the plugin config: a reference carrying its own\n // `relationTo` is checkable without it, and a bare id is not — which is the\n // honest limit of validating a form in isolation.\n if (\n s.confirmationType === \"relationship\" &&\n !namesADocument(s.redirectPage)\n ) {\n errors.push({\n path: \"settings.redirectPage\",\n message: \"A page is required when confirmation type is 'relationship'\",\n code: \"REDIRECT_PAGE_REQUIRED\",\n });\n }\n}\n\n/**\n * Whether a stored redirect value names a document at all.\n *\n * Derived from the shared reader rather than restating its rules, with an\n * empty collection list: that accepts every shape carrying its own\n * `relationTo` and rejects the ones naming nothing. A bare id is the case this\n * cannot judge here, since which collection it belongs to is plugin\n * configuration this entry point never sees — so it is accepted rather than\n * reported, because refusing a valid form is the worse error.\n */\nfunction namesADocument(value: unknown): boolean {\n if (typeof value === \"string\") return value.length > 0;\n return parseRedirectReference(value, []) !== null;\n}\n\n/**\n * Validates form access control.\n */\nfunction validateAccess(access: unknown, errors: FormValidationError[]): void {\n if (!access) {\n return; // Access is optional\n }\n\n if (typeof access !== \"object\" || Array.isArray(access)) {\n errors.push({\n path: \"access\",\n message: \"Access control must be an object\",\n code: \"ACCESS_INVALID_TYPE\",\n });\n return;\n }\n\n const accessObj = access as Record<string, unknown>;\n const validAccessKeys = [\"submit\", \"read\"];\n\n for (const key of validAccessKeys) {\n const fn = accessObj[key];\n if (fn !== undefined && typeof fn !== \"function\") {\n errors.push({\n path: `access.${key}`,\n message: `Access control '${key}' must be a function`,\n code: \"ACCESS_FUNCTION_INVALID\",\n });\n }\n }\n}\n\n// ============================================================\n// Main Validation Function\n// ============================================================\n\n/**\n * Validates a complete form configuration.\n *\n * This is the main entry point for form validation. It performs\n * comprehensive validation including:\n * - Slug format and reserved name checking\n * - Field name validation\n * - Field-specific validation (options, min/max, etc.)\n * - Duplicate field name detection\n * - Conditional logic validation\n * - Settings validation\n * - Access function type validation\n *\n * @param config - The form configuration to validate\n * @returns Validation result with any errors found\n *\n * @example\n * ```typescript\n * import { validateFormConfig } from '@nextly/plugin-form-builder';\n *\n * const config = {\n * slug: 'contact-form',\n * fields: [\n * { type: 'text', name: 'firstName', label: 'First Name' },\n * { type: 'email', name: 'email', label: 'Email', required: true },\n * ],\n * };\n *\n * const result = validateFormConfig(config);\n *\n * if (!result.valid) {\n * result.errors.forEach(err => {\n * console.error(`[${err.code}] ${err.path}: ${err.message}`);\n * });\n * }\n * ```\n */\nexport function validateFormConfig(config: FormConfig): FormValidationResult {\n const errors: FormValidationError[] = [];\n\n // A removed option is not an error, since the rest of the config still\n // works, but it is said here too, so a config checked this way is told.\n warnIfWebhooksConfigured(config);\n\n // Validate slug\n validateSlug(config.slug, errors);\n\n // Validate fields\n validateFields(config.fields, errors);\n\n // Validate settings\n validateSettings(config.settings, errors);\n\n // Validate access control\n validateAccess(config.access, errors);\n\n return {\n valid: errors.length === 0,\n errors,\n };\n}\n\n/**\n * Throws an error if the form configuration is invalid.\n *\n * This is a convenience wrapper around `validateFormConfig` that\n * throws a descriptive error instead of returning a result object.\n *\n * @param config - The form configuration to validate\n * @throws Error if validation fails with all error messages\n *\n * @example\n * ```typescript\n * import { assertValidFormConfig } from '@nextly/plugin-form-builder';\n *\n * // Throws if invalid\n * assertValidFormConfig(config);\n * ```\n */\nexport function assertValidFormConfig(config: FormConfig): void {\n const result = validateFormConfig(config);\n\n if (!result.valid) {\n const errorMessages = result.errors\n .map(err => ` - [${err.code}] ${err.path}: ${err.message}`)\n .join(\"\\n\");\n\n throw new Error(\n `Invalid form config for '${config.slug || \"unknown\"}':\\n${errorMessages}`\n );\n }\n}\n","/**\n * Form Field Helpers\n *\n * Convenient factory functions for creating form field configurations.\n * These helpers eliminate the need to manually specify the `type` property,\n * providing a cleaner, more ergonomic API for form definitions.\n *\n * @module config/field-helpers\n *\n * @example\n * ```typescript\n * import {\n * text,\n * email,\n * select,\n * checkbox,\n * option,\n * } from '@nextly/plugin-form-builder';\n *\n * const contactFormFields = [\n * text({ name: 'firstName', label: 'First Name', required: true }),\n * text({ name: 'lastName', label: 'Last Name', required: true }),\n * email({ name: 'email', label: 'Email Address', required: true }),\n * select({\n * name: 'subject',\n * label: 'Subject',\n * options: [\n * option('General Inquiry'),\n * option('Support Request'),\n * option('Feedback'),\n * ],\n * }),\n * checkbox({ name: 'subscribe', label: 'Subscribe to newsletter' }),\n * ];\n * ```\n */\n\nimport type {\n TextFormField,\n EmailFormField,\n NumberFormField,\n PhoneFormField,\n UrlFormField,\n TextareaFormField,\n SelectFormField,\n CheckboxFormField,\n RadioFormField,\n FileFormField,\n DateFormField,\n TimeFormField,\n HiddenFormField,\n} from \"../types\";\n\n// ============================================================\n// Utility Types\n// ============================================================\n\n/**\n * Option for select and radio fields.\n */\nexport interface FormFieldOption {\n label: string;\n value: string;\n}\n\n// ============================================================\n// Utility Helpers\n// ============================================================\n\n/**\n * Creates an option for select or radio fields.\n *\n * If no value is provided, the label is converted to lowercase with\n * spaces replaced by underscores.\n *\n * @param label - Display label for the option\n * @param value - Optional value (defaults to lowercase label with underscores)\n * @returns Option object with label and value\n *\n * @example\n * ```typescript\n * // Value derived from label\n * option('United States') // { label: 'United States', value: 'united_states' }\n *\n * // Explicit value\n * option('United States', 'us') // { label: 'United States', value: 'us' }\n *\n * // Usage in select field\n * select({\n * name: 'country',\n * label: 'Country',\n * options: [\n * option('United States', 'us'),\n * option('Canada', 'ca'),\n * option('Mexico', 'mx'),\n * ],\n * })\n * ```\n */\nexport const option = (label: string, value?: string): FormFieldOption => ({\n label,\n value: value ?? label.toLowerCase().replace(/\\s+/g, \"_\"),\n});\n\n// ============================================================\n// Text Input Field Helpers\n// ============================================================\n\n/**\n * Creates a text input field configuration.\n *\n * Text fields store simple string values with optional constraints\n * like min/max length and pattern validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete text field configuration\n *\n * @example\n * ```typescript\n * // Basic text field\n * text({ name: 'firstName', label: 'First Name', required: true })\n *\n * // Text with validation\n * text({\n * name: 'username',\n * label: 'Username',\n * required: true,\n * placeholder: 'Enter username',\n * validation: {\n * minLength: 3,\n * maxLength: 20,\n * pattern: '^[a-zA-Z0-9_]+$',\n * },\n * })\n * ```\n */\nexport const text = (config: Omit<TextFormField, \"type\">): TextFormField => ({\n ...config,\n type: \"text\",\n});\n\n/**\n * Creates an email input field configuration.\n *\n * Email fields provide built-in email format validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete email field configuration\n *\n * @example\n * ```typescript\n * // Basic email field\n * email({ name: 'email', label: 'Email Address', required: true })\n *\n * // Email with placeholder\n * email({\n * name: 'workEmail',\n * label: 'Work Email',\n * required: true,\n * placeholder: 'you@nextly.local',\n * })\n * ```\n */\nexport const email = (\n config: Omit<EmailFormField, \"type\">\n): EmailFormField => ({\n ...config,\n type: \"email\",\n});\n\n/**\n * Creates a phone input field configuration.\n *\n * Phone fields are optimized for phone number entry with\n * appropriate keyboard on mobile devices.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete phone field configuration\n *\n * @example\n * ```typescript\n * // Basic phone field\n * phone({ name: 'phone', label: 'Phone Number', required: true })\n *\n * // Phone with placeholder\n * phone({\n * name: 'mobile',\n * label: 'Mobile Number',\n * placeholder: '+1 (555) 123-4567',\n * })\n * ```\n */\nexport const phone = (\n config: Omit<PhoneFormField, \"type\">\n): PhoneFormField => ({\n ...config,\n type: \"phone\",\n});\n\n/**\n * Creates a URL input field configuration.\n *\n * URL fields provide built-in URL format validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete URL field configuration\n *\n * @example\n * ```typescript\n * // Basic URL field\n * url({ name: 'website', label: 'Website URL' })\n *\n * // URL with placeholder\n * url({\n * name: 'portfolio',\n * label: 'Portfolio URL',\n * placeholder: 'https://example.com',\n * })\n * ```\n */\nexport const url = (config: Omit<UrlFormField, \"type\">): UrlFormField => ({\n ...config,\n type: \"url\",\n});\n\n/**\n * Creates a textarea field configuration.\n *\n * Textarea fields provide multi-line text input with configurable\n * row count and length validation.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete textarea field configuration\n *\n * @example\n * ```typescript\n * // Basic textarea\n * textarea({ name: 'message', label: 'Message', required: true })\n *\n * // Textarea with rows and validation\n * textarea({\n * name: 'description',\n * label: 'Description',\n * rows: 5,\n * validation: {\n * minLength: 10,\n * maxLength: 500,\n * },\n * })\n * ```\n */\nexport const textarea = (\n config: Omit<TextareaFormField, \"type\">\n): TextareaFormField => ({\n ...config,\n type: \"textarea\",\n});\n\n// ============================================================\n// Numeric Field Helpers\n// ============================================================\n\n/**\n * Creates a number input field configuration.\n *\n * Number fields store numeric values with optional min/max/step\n * constraints.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete number field configuration\n *\n * @example\n * ```typescript\n * // Basic number field\n * number({ name: 'age', label: 'Age', required: true })\n *\n * // Number with range validation\n * number({\n * name: 'quantity',\n * label: 'Quantity',\n * required: true,\n * validation: {\n * min: 1,\n * max: 100,\n * step: 1,\n * },\n * })\n * ```\n */\nexport const number = (\n config: Omit<NumberFormField, \"type\">\n): NumberFormField => ({\n ...config,\n type: \"number\",\n});\n\n// ============================================================\n// Selection Field Helpers\n// ============================================================\n\n/**\n * Creates a select dropdown field configuration.\n *\n * Select fields display a dropdown list of options. Use the `option()`\n * helper to create options.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete select field configuration\n *\n * @example\n * ```typescript\n * // Basic select\n * select({\n * name: 'country',\n * label: 'Country',\n * required: true,\n * options: [\n * option('United States', 'us'),\n * option('Canada', 'ca'),\n * option('Mexico', 'mx'),\n * ],\n * })\n *\n * // Multi-select\n * select({\n * name: 'interests',\n * label: 'Interests',\n * allowMultiple: true,\n * options: [\n * option('Technology'),\n * option('Design'),\n * option('Business'),\n * ],\n * })\n * ```\n */\nexport const select = (\n config: Omit<SelectFormField, \"type\">\n): SelectFormField => ({\n ...config,\n type: \"select\",\n});\n\n/**\n * Creates a single checkbox field configuration.\n *\n * Checkbox fields store boolean values (true/false).\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete checkbox field configuration\n *\n * @example\n * ```typescript\n * // Terms agreement checkbox\n * checkbox({\n * name: 'agreeToTerms',\n * label: 'I agree to the terms and conditions',\n * required: true,\n * })\n *\n * // Newsletter subscription\n * checkbox({\n * name: 'subscribe',\n * label: 'Subscribe to our newsletter',\n * defaultValue: true,\n * })\n * ```\n */\nexport const checkbox = (\n config: Omit<CheckboxFormField, \"type\">\n): CheckboxFormField => ({\n ...config,\n type: \"checkbox\",\n});\n\n/**\n * Creates a radio group field configuration.\n *\n * Radio group fields allow selecting exactly one option from a list.\n * Use the `option()` helper to create options.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete radio field configuration\n *\n * @example\n * ```typescript\n * radio({\n * name: 'contactMethod',\n * label: 'Preferred Contact Method',\n * required: true,\n * options: [\n * option('Email', 'email'),\n * option('Phone', 'phone'),\n * option('SMS', 'sms'),\n * ],\n * })\n * ```\n */\nexport const radio = (\n config: Omit<RadioFormField, \"type\">\n): RadioFormField => ({\n ...config,\n type: \"radio\",\n});\n\n// ============================================================\n// File Field Helpers\n// ============================================================\n\n/**\n * Creates a file upload field configuration.\n *\n * File fields allow users to upload files with configurable\n * accepted types, size limits, and multiple file support.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete file field configuration\n *\n * @example\n * ```typescript\n * // Single file upload\n * file({\n * name: 'resume',\n * label: 'Upload Resume',\n * required: true,\n * accept: 'application/pdf,.doc,.docx',\n * maxFileSize: 5242880, // 5MB\n * })\n *\n * // Multiple images\n * file({\n * name: 'photos',\n * label: 'Upload Photos',\n * accept: 'image/*',\n * multiple: true,\n * maxFileSize: 10485760, // 10MB\n * })\n * ```\n */\nexport const file = (config: Omit<FileFormField, \"type\">): FileFormField => ({\n ...config,\n type: \"file\",\n});\n\n// ============================================================\n// Date/Time Field Helpers\n// ============================================================\n\n/**\n * Creates a date picker field configuration.\n *\n * Date fields provide a date picker with optional min/max constraints.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete date field configuration\n *\n * @example\n * ```typescript\n * // Basic date field\n * date({ name: 'birthdate', label: 'Date of Birth', required: true })\n *\n * // Date with range constraints\n * date({\n * name: 'eventDate',\n * label: 'Event Date',\n * required: true,\n * min: '2024-01-01',\n * max: '2024-12-31',\n * })\n * ```\n */\nexport const date = (config: Omit<DateFormField, \"type\">): DateFormField => ({\n ...config,\n type: \"date\",\n});\n\n/**\n * Creates a time picker field configuration.\n *\n * Time fields provide a time picker for selecting time values.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete time field configuration\n *\n * @example\n * ```typescript\n * // Basic time field\n * time({ name: 'appointmentTime', label: 'Preferred Time', required: true })\n *\n * // Time with default\n * time({\n * name: 'meetingTime',\n * label: 'Meeting Time',\n * defaultValue: '09:00',\n * })\n * ```\n */\nexport const time = (config: Omit<TimeFormField, \"type\">): TimeFormField => ({\n ...config,\n type: \"time\",\n});\n\n// ============================================================\n// Special Field Helpers\n// ============================================================\n\n/**\n * Creates a hidden field configuration.\n *\n * Hidden fields store values that are not visible to users but\n * are included in the submission data. Useful for tracking\n * metadata like source, campaign, or referrer.\n *\n * @param config - Field configuration without the `type` property\n * @returns Complete hidden field configuration\n *\n * @example\n * ```typescript\n * // Track form source\n * hidden({ name: 'source', label: 'Source', defaultValue: 'website' })\n *\n * // Campaign tracking\n * hidden({ name: 'campaign', label: 'Campaign', defaultValue: 'summer-sale' })\n * ```\n */\nexport const hidden = (\n config: Omit<HiddenFormField, \"type\">\n): HiddenFormField => ({\n ...config,\n type: \"hidden\",\n});\n","/**\n * @nextly/plugin-form-builder\n *\n * Visual form builder plugin for Nextly CMS.\n * Provides drag-and-drop form building, submission management,\n * email notifications, and spam protection.\n *\n * @packageDocumentation\n */\n\n// ============================================================\n// Main Plugin Export\n// ============================================================\n\n// ============================================================\n// Default Plugin Instance\n// ============================================================\n\n/**\n * Pre-configured form builder plugin for simple usage.\n *\n * This is a default instance with all defaults applied.\n * For customization, use `formBuilder()` instead.\n *\n * @example Simple usage - just add the plugin!\n * ```typescript\n * // nextly.config.ts\n * import { defineConfig } from 'nextly';\n * import { formBuilderPlugin } from '@nextly/plugin-form-builder';\n *\n * export default defineConfig({\n * plugins: [formBuilderPlugin],\n * collections: [Posts, Users, Media], // Your collections only\n * });\n * ```\n *\n * @example With customization\n * ```typescript\n * import { formBuilder } from '@nextly/plugin-form-builder';\n *\n * const myFormPlugin = formBuilder({\n * notifications: { defaultFrom: 'noreply@example.com' },\n * });\n *\n * export default defineConfig({\n * plugins: [myFormPlugin.plugin],\n * });\n * ```\n */\nimport { formBuilder } from \"./plugin\";\n\nexport {\n formBuilder,\n getFormBuilderConfig,\n type NextlyPlugin,\n type FormBuilderPluginResult,\n} from \"./plugin\";\nexport const formBuilderPlugin = formBuilder().plugin;\n\n// ============================================================\n// Type Exports\n// ============================================================\n\n// Plugin configuration types\nexport type {\n FormBuilderPluginOptions,\n ResolvedFormBuilderConfig,\n FieldBlockConfig,\n} from \"./types\";\n\n// Form field types (discriminated union)\nexport type {\n FormField,\n FormFieldType,\n FormFieldTypeId,\n AnyFormField,\n CustomFormField,\n BaseFormField,\n TextFormField,\n EmailFormField,\n NumberFormField,\n PhoneFormField,\n UrlFormField,\n TextareaFormField,\n SelectFormField,\n CheckboxFormField,\n RadioFormField,\n FileFormField,\n DateFormField,\n TimeFormField,\n HiddenFormField,\n} from \"./types\";\nexport { isKnownFormField, BUILT_IN_FORM_FIELD_TYPES } from \"./types\";\n\n// Form configuration types\nexport type {\n FormConfig,\n FormSettings,\n FormNotifications,\n FormAccess,\n FormHooks,\n FormHookContext,\n} from \"./types\";\n\n// Document types\nexport type { FormDocument, SubmissionDocument, FormSubmission } from \"./types\";\n\n// Notification rule type (stored on the form; edited by the builder UI)\nexport type { FormNotification, FormNotificationItem } from \"./types\";\n\n// Validation and conditional logic types\nexport type {\n ValidationRules,\n ConditionalLogic,\n ConditionalLogicCondition,\n} from \"./types\";\n\n// Email types\nexport type { EmailConfig } from \"./types\";\n\n// ============================================================\n// Utility Exports\n// ============================================================\n\n// Type guards\nexport {\n isFormField,\n isFormFieldType,\n isTextFormField,\n isEmailFormField,\n isSelectFormField,\n isCheckboxFormField,\n isFileFormField,\n} from \"./types\";\n\n// ============================================================\n// Config Utilities (validation & defaults)\n// ============================================================\n\n// Validation utilities\nexport {\n validateFormConfig,\n assertValidFormConfig,\n RESERVED_FORM_SLUGS,\n type FormValidationError,\n type FormValidationErrorCode,\n type FormValidationResult,\n} from \"./config\";\n\n// Default application utilities\nexport {\n applyFormDefaults,\n applyFieldDefaults,\n createFormConfig,\n toTitleCase,\n pluralize,\n DEFAULT_FORM_SETTINGS,\n DEFAULT_NOTIFICATION_SETTINGS,\n} from \"./config\";\n\n// ============================================================\n// Field Helper Functions\n// ============================================================\n\n// Utility helper\nexport { option, type FormFieldOption } from \"./config\";\n\n// Text input field helpers\nexport { text, email, phone, url, textarea } from \"./config\";\n\n// Numeric field helpers\nexport { number } from \"./config\";\n\n// Selection field helpers\nexport { select, checkbox, radio } from \"./config\";\n\n// File field helpers\nexport { file } from \"./config\";\n\n// Date/time field helpers\nexport { date, time } from \"./config\";\n\n// Special field helpers\nexport { hidden } from \"./config\";\n\n// ============================================================\n// Conditional Logic Utilities\n// ============================================================\n\n// Conditional logic evaluation\nexport {\n evaluateConditions,\n isValidComparisonOperator,\n getSupportedComparisonOperators,\n type ComparisonOperator,\n} from \"./utils\";\n\n// ============================================================\n// Schema Generation & Validation Utilities\n// ============================================================\n\n// Zod schema generation for form validation\nexport {\n generateZodSchema,\n transformFormData,\n validateFormData,\n getValidationErrors,\n} from \"./utils\";\n\n// ============================================================\n// Export Utilities (CSV, JSON)\n// ============================================================\n\n// Export format utilities\nexport {\n exportToCSV,\n exportToJSON,\n formatExportValue,\n downloadFile,\n generateExportFilename,\n exportAndDownload,\n type CSVExportOptions,\n type JSONExportOptions,\n type ExportedJSON,\n} from \"./utils\";\n\n// ============================================================\n// Form Submission Handlers\n// ============================================================\n\n// Main submission handler\nexport {\n submitForm,\n validateSubmission,\n isFormAcceptingSubmissions,\n getFormSubmissionStats,\n type SubmitFormOptions,\n type SubmitFormResult,\n type SubmitFormContext,\n} from \"./handlers\";\n\n// Spam detection utilities\nexport {\n checkSpam,\n type SpamCheckConfig,\n type SpamCheckOptions,\n type SpamCheckResult,\n} from \"./handlers\";\n\n// ============================================================\n// Collection Exports (for advanced usage)\n// ============================================================\n\nexport { formsCollection, submissionsCollection } from \"./collections\";\n"],"mappings":"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","names":["NextlyError","option","url","text","textarea","select","json","date","relationship","relationship","json","select","text","textarea","date","schema","date","formAvailability","NextlyError","NO_SUCH_FORM","NextlyError","NextlyError","url","url","formAvailability","NextlyError","NO_SUCH_FORM","url","NextlyError","email","text","textarea","select","checkbox","date"]}