443 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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;
const nullableProfileOverrides = nullableProfileFields.reduce<Record<string, z.ZodTypeAny>>(
(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<string, unknown>).client_id),
owner_user_id: coerceOptionalNumber((raw as Record<string, unknown>).owner_user_id),
userid: coerceOptionalNumber((raw as Record<string, unknown>).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,
submittedAt: z.string().datetime().nullable(),
reviewedAt: z.string().datetime().nullable(),
reviewerNotes: z.string().nullable(),
});
// ============================================================================
// Exported Types (Public API)
// ============================================================================
export type User = z.infer<typeof userSchema>;
export type UserAuth = z.infer<typeof userAuthSchema>;
export type UserRole = "USER" | "ADMIN";
export type Address = z.infer<typeof addressSchema>;
export type AddressFormData = z.infer<typeof addressFormSchema>;
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
export type ProfileDisplayData = z.infer<typeof profileDisplayDataSchema>;
export type ResidenceCardVerificationStatus = z.infer<typeof residenceCardVerificationStatusSchema>;
export type ResidenceCardVerification = z.infer<typeof residenceCardVerificationSchema>;
// 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<typeof whmcsClientSchema>;
export type EmailPreferences = z.infer<typeof emailPreferencesSchema>;
export type SubUser = z.infer<typeof subUserSchema>;
export type Stats = z.infer<typeof statsSchema>;
// Export schemas for provider use
export { emailPreferencesSchema, subUserSchema, statsSchema };