Group C fixes
All checks were successful
Build and Push / build (push) Successful in 1m47s

This commit is contained in:
2026-04-29 17:20:58 +02:00
parent eeef108f7e
commit 49d81190d4
8 changed files with 767 additions and 269 deletions

114
src/lib/validation.ts Normal file
View File

@@ -0,0 +1,114 @@
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];
/**
* Billing address — every field required at minimum non-empty length.
* Postal code rules vary too much across DACH+ to enforce a single
* regex usefully; we settle for "non-empty, ≤ 12 chars". Country is a
* fixed enum to prevent free-text typos that break invoicing.
*/
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",
}),
});
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;
}