/** * Customer Domain - Schemas * * Zod validation schemas for customer domain types. * Pattern matches billing and subscriptions domains. * * Architecture: * - UserAuth: Auth state from portal database (Prisma) * - WhmcsClient: Full WHMCS data (raw field names, internal to providers) * - User: API response type (normalized camelCase) */ import { z } from "zod"; import { countryCodeSchema } from "../common/schema"; // ============================================================================ // Helper Schemas // ============================================================================ const stringOrNull = z.union([z.string(), z.null()]); const booleanLike = z.union([z.boolean(), z.number(), z.string()]); const numberLike = z.union([z.number(), z.string()]); /** * Normalize boolean-like values to actual booleans */ const normalizeBoolean = (value: unknown): boolean | null | undefined => { if (value === undefined) return undefined; if (value === null) return null; if (typeof value === "boolean") return value; if (typeof value === "number") return value === 1; if (typeof value === "string") { const normalized = value.trim().toLowerCase(); return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; } return null; }; // ============================================================================ // Address Schemas // ============================================================================ /** * Address schema - matches pattern from other domains (not "CustomerAddress") */ export const addressSchema = z.object({ address1: stringOrNull.optional(), address2: stringOrNull.optional(), city: stringOrNull.optional(), state: stringOrNull.optional(), postcode: stringOrNull.optional(), country: stringOrNull.optional(), countryCode: stringOrNull.optional(), phoneNumber: stringOrNull.optional(), phoneCountryCode: stringOrNull.optional(), }); export const addressFormSchema = z.object({ address1: z.string().min(1, "Address line 1 is required").max(200, "Address line 1 is too long").trim(), address2: z.string().max(200, "Address line 2 is too long").trim().optional(), city: z.string().min(1, "City is required").max(100, "City name is too long").trim(), state: z.string().min(1, "State/Prefecture is required").max(100, "State/Prefecture name is too long").trim(), postcode: z.string().min(1, "Postcode is required").max(20, "Postcode is too long").trim(), country: z.string().min(1, "Country is required").max(100, "Country name is too long").trim(), countryCode: countryCodeSchema.optional(), phoneNumber: z.string().optional(), phoneCountryCode: z.string().optional(), }); // ============================================================================ // UserAuth Schema (Portal Database - Auth State Only) // ============================================================================ /** * UserAuth - Authentication state from portal database * * Source: Portal database (Prisma) * Provider: customer/providers/portal/ * * Contains ONLY auth-related fields: * - User ID, email, role * - Email verification status * - MFA enabled status * - Last login timestamp */ export const userAuthSchema = z.object({ id: z.string().uuid(), email: z.string().email(), role: z.enum(["USER", "ADMIN"]), emailVerified: z.boolean(), mfaEnabled: z.boolean(), lastLoginAt: z.string().optional(), createdAt: z.string(), updatedAt: z.string(), }); // ============================================================================ // WHMCS-Specific Schemas (Internal to Providers) // ============================================================================ /** * Email preferences from WHMCS * Internal to Providers.Whmcs namespace */ const emailPreferencesSchema = z.object({ general: booleanLike.optional(), invoice: booleanLike.optional(), support: booleanLike.optional(), product: booleanLike.optional(), domain: booleanLike.optional(), affiliate: booleanLike.optional(), }).transform(prefs => ({ general: normalizeBoolean(prefs.general), invoice: normalizeBoolean(prefs.invoice), support: normalizeBoolean(prefs.support), product: normalizeBoolean(prefs.product), domain: normalizeBoolean(prefs.domain), affiliate: normalizeBoolean(prefs.affiliate), })); /** * Sub-user from WHMCS * Internal to Providers.Whmcs namespace */ const subUserSchema = z.object({ id: numberLike, name: z.string(), email: z.string(), is_owner: booleanLike.optional(), }).transform(user => ({ id: Number(user.id), name: user.name, email: user.email, is_owner: normalizeBoolean(user.is_owner), })); /** * Billing stats from WHMCS * Internal to Providers.Whmcs namespace */ const statsSchema = z.record( z.string(), z.union([z.string(), z.number(), z.boolean()]) ).optional(); /** * WhmcsClient - Full WHMCS client data * * Raw WHMCS structure with field names as they come from the API. * Internal to Providers.Whmcs namespace - not exported at top level. * * Includes: * - Profile data (firstname, lastname, companyname, etc.) * - Billing info (currency_code, defaultgateway, status) * - Preferences (email_preferences, allowSingleSignOn) * - Relations (users, stats, customfields) */ export const whmcsClientSchema = z.object({ id: numberLike, email: z.string(), // Profile (raw WHMCS field names) firstname: z.string().nullable().optional(), lastname: z.string().nullable().optional(), fullname: z.string().nullable().optional(), companyname: z.string().nullable().optional(), phonenumber: z.string().nullable().optional(), phonenumberformatted: z.string().nullable().optional(), telephoneNumber: z.string().nullable().optional(), // Billing & Payment (raw WHMCS field names) status: z.string().nullable().optional(), language: z.string().nullable().optional(), defaultgateway: z.string().nullable().optional(), defaultpaymethodid: numberLike.nullable().optional(), currency: numberLike.nullable().optional(), currency_code: z.string().nullable().optional(), // snake_case from WHMCS tax_id: z.string().nullable().optional(), // Preferences (raw WHMCS field names) allowSingleSignOn: booleanLike.nullable().optional(), email_verified: booleanLike.nullable().optional(), // snake_case from WHMCS marketing_emails_opt_in: booleanLike.nullable().optional(), // snake_case from WHMCS // Metadata (raw WHMCS field names) notes: z.string().nullable().optional(), datecreated: z.string().nullable().optional(), lastlogin: z.string().nullable().optional(), // Relations address: addressSchema.nullable().optional(), email_preferences: emailPreferencesSchema.nullable().optional(), // snake_case from WHMCS customfields: z.record(z.string(), z.string()).optional(), users: z.array(subUserSchema).optional(), stats: statsSchema.optional(), }).transform(data => ({ ...data, // Normalize types only, keep field names as-is id: typeof data.id === 'number' ? data.id : Number(data.id), allowSingleSignOn: normalizeBoolean(data.allowSingleSignOn), email_verified: normalizeBoolean(data.email_verified), marketing_emails_opt_in: normalizeBoolean(data.marketing_emails_opt_in), defaultpaymethodid: data.defaultpaymethodid ? Number(data.defaultpaymethodid) : null, currency: data.currency ? Number(data.currency) : null, })); // ============================================================================ // User Schema (API Response - Normalized camelCase) // ============================================================================ /** * User - Complete user profile for API responses * * Composition: UserAuth (portal DB) + WhmcsClient (WHMCS) * Field names normalized to camelCase for API consistency * * Use combineToUser() helper to construct from sources */ export const userSchema = userAuthSchema.extend({ // Profile fields (normalized from WHMCS) firstname: z.string().nullable().optional(), lastname: z.string().nullable().optional(), fullname: z.string().nullable().optional(), companyname: z.string().nullable().optional(), phonenumber: z.string().nullable().optional(), language: z.string().nullable().optional(), currencyCode: z.string().nullable().optional(), // Normalized from currency_code address: addressSchema.optional(), }); // ============================================================================ // Helper Functions // ============================================================================ /** * Convert address form data to address request format * Trims strings and converts empty strings to null */ export function addressFormToRequest(form: AddressFormData): Address { const emptyToNull = (value?: string | null) => { if (value === undefined) return undefined; const trimmed = value?.trim(); return trimmed ? trimmed : null; }; return addressSchema.parse({ address1: emptyToNull(form.address1), address2: emptyToNull(form.address2 ?? null), city: emptyToNull(form.city), state: emptyToNull(form.state), postcode: emptyToNull(form.postcode), country: emptyToNull(form.country), countryCode: emptyToNull(form.countryCode ?? null), phoneNumber: emptyToNull(form.phoneNumber ?? null), phoneCountryCode: emptyToNull(form.phoneCountryCode ?? null), }); } /** * Combine UserAuth and WhmcsClient into User * * This is the single source of truth for constructing User from its sources. * Maps raw WHMCS field names to normalized User field names. * * @param userAuth - Authentication state from portal database * @param whmcsClient - Full client data from WHMCS * @returns User object for API responses */ export function combineToUser(userAuth: UserAuth, whmcsClient: WhmcsClient): User { return userSchema.parse({ // Auth state from portal DB id: userAuth.id, email: userAuth.email, role: userAuth.role, emailVerified: userAuth.emailVerified, mfaEnabled: userAuth.mfaEnabled, lastLoginAt: userAuth.lastLoginAt, createdAt: userAuth.createdAt, updatedAt: userAuth.updatedAt, // Profile from WHMCS (map raw names to normalized) firstname: whmcsClient.firstname || null, lastname: whmcsClient.lastname || null, fullname: whmcsClient.fullname || null, companyname: whmcsClient.companyname || null, phonenumber: whmcsClient.phonenumberformatted || whmcsClient.phonenumber || whmcsClient.telephoneNumber || null, language: whmcsClient.language || null, currencyCode: whmcsClient.currency_code || null, // Normalize snake_case address: whmcsClient.address || undefined, }); } // ============================================================================ // Exported Types (Public API) // ============================================================================ export type User = z.infer; export type UserAuth = z.infer; export type UserRole = "USER" | "ADMIN"; export type Address = z.infer; export type AddressFormData = z.infer; // ============================================================================ // Internal Types (For Providers) // ============================================================================ export type WhmcsClient = z.infer; export type EmailPreferences = z.infer; export type SubUser = z.infer; export type Stats = z.infer; // Export schemas for provider use export { emailPreferencesSchema, subUserSchema, statsSchema };