165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
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<SupportedCountry, RegExp> = {
|
|
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<SupportedCountry, string> = {
|
|
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<typeof billingAddressSchema>;
|
|
|
|
/**
|
|
* 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<typeof onboardingSchema>;
|
|
|
|
/**
|
|
* 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<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const issue of err.issues) {
|
|
const key = issue.path.join(".");
|
|
if (!(key in out)) out[key] = issue.message;
|
|
}
|
|
return out;
|
|
}
|