/** * 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 { whmcsRequiredNumber, whmcsOptionalNumber, whmcsOptionalBoolean, } from "../common/providers/whmcs-utils/index.js"; import { whmcsClientSchema as whmcsRawClientSchema, whmcsCustomFieldSchema, } from "./providers/whmcs/raw.types.js"; // ============================================================================ // Helper Schemas // ============================================================================ const stringOrNull = z.union([z.string(), z.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(), }); // ============================================================================ // 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: whmcsOptionalBoolean, invoice: whmcsOptionalBoolean, support: whmcsOptionalBoolean, product: whmcsOptionalBoolean, domain: whmcsOptionalBoolean, affiliate: whmcsOptionalBoolean, }); /** * Sub-user from WHMCS * Internal to Providers.Whmcs namespace */ const subUserSchema = z.object({ id: whmcsRequiredNumber, name: z.string(), email: z.string(), is_owner: whmcsOptionalBoolean, }); /** * 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 normalizeCustomFields = (input: unknown): unknown => { if (!input) return input; if (Array.isArray(input)) return input; if (typeof input === "object" && input !== null && "customfield" in input) { const cf = (input as Record)["customfield"]; if (Array.isArray(cf)) return cf; return cf ? [cf] : input; } return input; }; const whmcsCustomFieldsSchema = z .preprocess(normalizeCustomFields, z.array(whmcsCustomFieldSchema).optional()) .optional(); const normalizeUsers = (input: unknown): unknown => { if (!input) return input; if (Array.isArray(input)) return input; if (typeof input === "object" && input !== null && "user" in input) { const u = (input as Record)["user"]; if (Array.isArray(u)) return u; return u ? [u] : input; } return input; }; const whmcsUsersSchema = z.preprocess(normalizeUsers, z.array(subUserSchema).optional()).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; const nullableProfileOverrides = nullableProfileFields.reduce>( (acc, field) => { acc[field] = z.string().nullable().optional(); return acc; }, {} ); export const whmcsClientSchema = whmcsRawClientSchema.extend({ ...nullableProfileOverrides, defaultpaymethodid: whmcsOptionalNumber.nullable(), currency: whmcsOptionalNumber.nullable(), allowSingleSignOn: whmcsOptionalBoolean.nullable(), email_verified: whmcsOptionalBoolean.nullable(), marketing_emails_opt_in: whmcsOptionalBoolean.nullable(), address: addressSchema.nullable().optional(), email_preferences: emailPreferencesSchema.nullable().optional(), customfields: whmcsCustomFieldsSchema, users: whmcsUsersSchema, stats: statsSchema.optional(), }); // ============================================================================ // 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; 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 | undefined; } { 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, 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 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 };