/** * 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.js"; import { whmcsClientSchema as whmcsRawClientSchema, whmcsCustomFieldSchema, } from "./providers/whmcs/raw.types.js"; // ============================================================================ // 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(), }); // ============================================================================ // Profile Edit Schemas // ============================================================================ /** * Profile edit form schema for frontend forms * Contains basic editable user profile fields (WHMCS field names) */ export const profileEditFormSchema = z.object({ email: z.string().email("Enter a valid email").trim(), phonenumber: z.string().optional(), }); /** * Profile display data - includes email for display (read-only) * Used for displaying profile information */ export const profileDisplayDataSchema = profileEditFormSchema.extend({ // no extra fields (kept for backwards compatibility) }); // ============================================================================ // 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(); const whmcsRawCustomFieldsArraySchema = z.array(whmcsCustomFieldSchema); const whmcsCustomFieldsSchema = z .union([ z.record(z.string(), z.string()), whmcsRawCustomFieldsArraySchema, z .object({ customfield: z.union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema]).optional(), }) .passthrough(), ]) .optional(); const whmcsUsersSchema = z .union([ z.array(subUserSchema), z .object({ user: z.union([subUserSchema, z.array(subUserSchema)]).optional(), }) .passthrough(), ]) .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) */ const nullableProfileFields = [ "firstname", "lastname", "fullname", "companyname", "phonenumber", "phonenumberformatted", "telephoneNumber", "status", "language", "defaultgateway", "currency_code", "tax_id", "notes", "datecreated", "lastlogin", ] as const; type NullableProfileKey = (typeof nullableProfileFields)[number]; const nullableProfileOverrides = nullableProfileFields.reduce>( (acc, field) => { acc[field] = z.string().nullable().optional(); return acc; }, {} ); export const whmcsClientSchema = whmcsRawClientSchema .extend({ ...nullableProfileOverrides, // Allow nullable numeric strings defaultpaymethodid: numberLike.nullable().optional(), currency: numberLike.nullable().optional(), allowSingleSignOn: booleanLike.nullable().optional(), email_verified: booleanLike.nullable().optional(), marketing_emails_opt_in: booleanLike.nullable().optional(), address: addressSchema.nullable().optional(), email_preferences: emailPreferencesSchema.nullable().optional(), customfields: whmcsCustomFieldsSchema, users: whmcsUsersSchema, stats: statsSchema.optional(), }) .transform(raw => { const coerceNumber = (value: unknown) => value === null || value === undefined ? null : Number(value); const coerceOptionalNumber = (value: unknown) => value === null || value === undefined ? undefined : Number(value); return { ...raw, id: Number(raw.id), client_id: coerceOptionalNumber((raw as Record).client_id), owner_user_id: coerceOptionalNumber((raw as Record).owner_user_id), userid: coerceOptionalNumber((raw as Record).userid), allowSingleSignOn: normalizeBoolean(raw.allowSingleSignOn), email_verified: normalizeBoolean(raw.email_verified), marketing_emails_opt_in: normalizeBoolean(raw.marketing_emails_opt_in), defaultpaymethodid: coerceNumber(raw.defaultpaymethodid), currency: coerceNumber(raw.currency), }; }); // ============================================================================ // User Schema (API Response - Normalized camelCase) // ============================================================================ /** * User - Complete user profile for API responses * * Composition: UserAuth (portal DB) + WhmcsClient (WHMCS) * Field names match WHMCS API exactly (no transformation) * * Use combineToUser() helper to construct from sources */ export const userSchema = userAuthSchema.extend({ // Profile fields (WHMCS field names - direct from API) 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(), currency_code: z.string().nullable().optional(), // WHMCS uses snake_case for this address: addressSchema.optional(), // Common portal-visible identifiers/custom fields (derived in BFF) sfNumber: z.string().nullable().optional(), dateOfBirth: z.string().nullable().optional(), gender: z.string().nullable().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), }); } /** * Convert profile form data to update request format * No transformation needed - form already uses WHMCS field names */ export function profileFormToRequest(form: ProfileEditFormData): { email: string; phonenumber?: string; } { return { email: form.email.trim(), phonenumber: form.phonenumber?.trim() || undefined, }; } /** * Combine UserAuth and WhmcsClient into User * * This is the single source of truth for constructing User from its sources. * No field name transformation - User schema uses WHMCS field names directly. * * @param userAuth - Authentication state from portal database * @param whmcsClient - Full client data from WHMCS * @returns User object for API responses with WHMCS field names */ 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 (no transformation - keep field names as-is) 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, currency_code: whmcsClient.currency_code || null, address: whmcsClient.address || undefined, }); } // ============================================================================ // Verification (Customer-facing) // ============================================================================ /** * Residence card verification status shown in the portal. * * Stored in Salesforce on the Account record. */ export const residenceCardVerificationStatusSchema = z.enum([ "not_submitted", "pending", "verified", "rejected", ]); export const residenceCardVerificationSchema = z.object({ status: residenceCardVerificationStatusSchema, filename: z.string().nullable(), mimeType: z.string().nullable(), sizeBytes: z.number().int().nonnegative().nullable(), submittedAt: z.string().datetime().nullable(), reviewedAt: z.string().datetime().nullable(), reviewerNotes: z.string().nullable(), }); // ============================================================================ // 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; export type ProfileEditFormData = z.infer; export type ProfileDisplayData = z.infer; export type ResidenceCardVerificationStatus = z.infer; export type ResidenceCardVerification = z.infer; // Convenience aliases export type UserProfile = User; // Alias for user profile export type AuthenticatedUser = User; // Alias for authenticated user context // ============================================================================ // 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 };