import { z } from "zod"; /** * Shared validation schemas for the onboarding wizard and the * registration form. Both client and server import from here so the * rules can't drift apart. * * Bug 12 motivation: until now, all wizard fields could be empty and * still submit — the server schema in `/api/onboarding` had every * billing field optional, and the client did no validation at all. * Required fields are now declared once, here, and used in three * places: * 1. The wizard's per-step `validateStep()` to gate `goNext()`. * 2. The wizard's submit handler to render inline errors. * 3. The server route's `safeParse()` so the rules are also * enforced on direct API calls. * * Don't mix UX-only state (e.g. "did the user touch this field yet") * into these schemas — that belongs in the wizard's render layer. * These schemas describe what the data has to look like, not the * progressive-disclosure rules. */ // ISO-3166-1 alpha-2 codes accepted in the country dropdown. DACH+ // neighbours: Switzerland, Germany, Austria, France, Italy, plus // Liechtenstein (Swiss customers with LI billing addresses are common // enough to include without inflating the list). Add to this set when // expanding into new markets. export const SUPPORTED_COUNTRIES = ["CH", "DE", "AT", "FR", "IT", "LI"] as const; export type SupportedCountry = (typeof SUPPORTED_COUNTRIES)[number]; /** * Country-specific postal-code patterns. Bug 33: previously a postal * code could be anything (e.g. "abc"), which broke invoicing. * * Patterns are deliberately conservative — they reject obviously wrong * input but don't try to be exhaustive valid-range checkers (e.g. CH * codes are 1000-9999 in practice but \d{4} accepts 0000; the post * office will reject downstream if it matters). If a future country * has multi-format codes (e.g. UK postcodes with the inner-outer * structure), add it as a regex here rather than trying to fit * every country into the same shape. */ const POSTAL_CODE_PATTERNS: Record = { CH: /^\d{4}$/, DE: /^\d{5}$/, AT: /^\d{4}$/, FR: /^\d{5}$/, IT: /^\d{5}$/, LI: /^\d{4}$/, }; /** * Postal-code expectation in human terms — used in error messages so * the user gets a useful hint ("expected 4 digits") rather than just * a regex failure. Keep in sync with POSTAL_CODE_PATTERNS. */ const POSTAL_CODE_HINTS: Record = { CH: "4 digits", DE: "5 digits", AT: "4 digits", FR: "5 digits", IT: "5 digits", LI: "4 digits", }; /** * Billing address — every field required at minimum non-empty length. * Postal code is validated against the chosen country (Bug 33). Country * is a fixed enum to prevent free-text typos that break invoicing. * * `superRefine` is the right hook here because we need to look at two * fields (country + postalCode) together. The error path is set on * `postalCode` so the wizard renders the inline error under the right * input rather than at the form root. */ export const billingAddressSchema = z .object({ // Company line is structurally optional — personal accounts leave it // empty by design (Bug 2). Server-side, the wizard's UI hides the // field for personals; the schema just doesn't require it. company: z.string().trim().max(100).optional().default(""), street: z.string().trim().min(1, "required").max(200), postalCode: z.string().trim().min(1, "required").max(12), city: z.string().trim().min(1, "required").max(100), country: z.enum(SUPPORTED_COUNTRIES, { message: "Please choose a country from the list", }), }) .superRefine((data, ctx) => { const pattern = POSTAL_CODE_PATTERNS[data.country]; if (!pattern.test(data.postalCode)) { ctx.addIssue({ code: "custom", path: ["postalCode"], message: `Invalid postal code (expected ${POSTAL_CODE_HINTS[data.country]})`, }); } }); export type BillingAddressInput = z.infer; /** * Per-step schemas for progressive validation. Each step validates only * the fields visible up to that point, so the user gets feedback at the * step they're on rather than at the end. * * The `welcome` step has nothing to validate. * The `configure` step requires a non-empty agentName. * The `billing` step requires a complete billing address (with the * optional company line). * The `confirm` step is the final submission and validates the union. */ export const configureStepSchema = z.object({ agentName: z.string().trim().min(1, "required").max(50), }); export const billingStepSchema = z.object({ billingAddress: billingAddressSchema, }); /** * Full onboarding payload. Used by the API route and by the wizard's * submit handler. `packageSecrets` is a free-shape map that gets * encrypted by the server before it touches the DB. */ export const onboardingSchema = z.object({ instanceName: z .string() .trim() .max(80) .optional() // Empty string from a form input → undefined so the DB stores NULL. .transform((v) => (v && v.length > 0 ? v : undefined)), agentName: z.string().trim().min(1, "required").max(50), soulMd: z.string().max(10_000).optional(), agentsMd: z.string().max(10_000).optional(), packages: z.array(z.string()).optional(), packageSecrets: z .record(z.string(), z.record(z.string(), z.string())) .optional(), billingAddress: billingAddressSchema, billingNotes: z.string().max(2_000).optional(), }); export type OnboardingPayload = z.infer; /** * Helper: flatten a Zod error into a flat field-path → message map. * The wizard uses this to look up errors per input by their path. * * Returns `{}` on success (i.e. caller shouldn't call this on a parsed * value; only on `safeParse(...).error`). Kept here rather than inline * so both the wizard and any future field-level form (e.g. settings * page reusing billingAddressSchema) can share it. */ export function fieldErrors(err: z.ZodError): Record { const out: Record = {}; for (const issue of err.issues) { const key = issue.path.join("."); if (!(key in out)) out[key] = issue.message; } return out; }