import { defineAction } from "@agent-native/core"; import { assertAccess } from "@agent-native/core/sharing"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { assertIntegrationUrlsAllowed } from "../server/lib/integrations.js"; import { invalidatePublicFormCache } from "../server/lib/public-form-ssr.js"; import { assertValidFields, normalizeFieldIds, } from "../server/lib/validate-fields.js"; import type { FormField, FormSettings } from "../shared/types.js"; import { assertPublishableForm } from "./lib/assert-publishable-form.js"; function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 60); } export default defineAction({ description: "Update an existing form, including settings.emailOnNewResponses to email the form owner when new responses arrive.", schema: z.object({ id: z.string().describe("Form ID (required)"), title: z.string().optional().describe("New title"), description: z.string().optional().describe("New description"), slug: z.string().optional().describe("New URL slug"), // Accept fields as a JSON string (agent CLI / older callers) or as an // actual array (UI POSTs JSON bodies via useActionMutation, which // serializes the FormField[] directly — Zod must accept both). fields: z .union([z.string(), z.array(z.any())]) .optional() .describe("Array of form fields (or JSON string of the same)"), settings: z .union([z.string(), z.record(z.string(), z.any())]) .optional() .describe( "Form settings object (or JSON string of the same). Set emailOnNewResponses=true to email the form owner for each new response.", ), status: z .enum(["draft", "published", "closed"]) .optional() .describe("New status"), }), run: async (args) => { await assertAccess("form", args.id, "editor"); const db = getDb(); const [existing] = await db .select() .from(schema.forms) .where(eq(schema.forms.id, args.id)) .limit(1); if (!existing) { throw new Error(`Form ${args.id} not found`); } const now = new Date().toISOString(); const updates: Record = { updatedAt: now }; if (args.title !== undefined) { updates.title = args.title; if (args.slug === undefined) { const idSuffix = args.id.slice(0, 6); updates.slug = slugify(args.title || "untitled") + "-" + idSuffix; } } if (args.description !== undefined) updates.description = args.description; if (args.slug !== undefined) updates.slug = args.slug; if (args.fields !== undefined) { let parsedFields: unknown; if (typeof args.fields === "string") { try { parsedFields = JSON.parse(args.fields); } catch { throw new Error("--fields must be valid JSON"); } } else { parsedFields = args.fields; } parsedFields = normalizeFieldIds(parsedFields); assertValidFields(parsedFields); updates.fields = JSON.stringify(parsedFields); } if (args.settings !== undefined) { let incomingSettings: FormSettings; if (typeof args.settings === "string") { try { incomingSettings = JSON.parse(args.settings) as FormSettings; } catch { throw new Error("--settings must be valid JSON"); } } else { incomingSettings = args.settings as unknown as FormSettings; } let existingSettings: FormSettings = {}; try { existingSettings = JSON.parse(existing.settings) as FormSettings; } catch { // Keep malformed legacy settings recoverable by replacing them with // the valid settings supplied by this update. } const parsedSettings = { ...existingSettings, ...incomingSettings }; // Reject blocked integration URLs at save time (private IPs, // cloud-metadata, non-http(s) schemes). fireIntegrations also // re-checks at runtime as defense-in-depth. assertIntegrationUrlsAllowed(parsedSettings); updates.settings = JSON.stringify(parsedSettings); } if (args.status !== undefined) updates.status = args.status; // Pre-publish validation. Reject the publish if the form is missing // required configuration that would make it unsubmittable. This also // catches the agent flipping status to "published" on an empty/broken // form. We only validate on transition INTO published — leaving an // already-published form alone (or unpublishing it) shouldn't error. if (args.status === "published") { // Use the incoming fields if provided, otherwise the existing row. const effectiveFieldsRaw = updates.fields !== undefined ? updates.fields : existing.fields; let effectiveFields: FormField[] = []; try { effectiveFields = typeof effectiveFieldsRaw === "string" ? (JSON.parse(effectiveFieldsRaw) as FormField[]) : ((effectiveFieldsRaw as unknown as FormField[]) ?? []); } catch { effectiveFields = []; } assertPublishableForm(effectiveFields); } await db .update(schema.forms) .set(updates) .where(eq(schema.forms.id, args.id)); const [row] = await db .select() .from(schema.forms) .where(eq(schema.forms.id, args.id)) .limit(1); invalidatePublicFormCache(existing, row); return { id: row!.id, title: row!.title, description: row!.description ?? undefined, slug: row!.slug, fields: JSON.parse(row!.fields) as FormField[], settings: JSON.parse(row!.settings) as FormSettings, status: row!.status, visibility: row!.visibility, ownerEmail: row!.ownerEmail, createdAt: row!.createdAt, updatedAt: row!.updatedAt, }; }, });