2025-10-08 16:31:42 +09:00
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
*/
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
import { z } from "zod";
|
|
|
|
|
|
2025-12-10 15:22:10 +09:00
|
|
|
import { countryCodeSchema } from "../common/schema.js";
|
|
|
|
|
import { whmcsClientSchema as whmcsRawClientSchema, whmcsCustomFieldSchema } from "./providers/whmcs/raw.types.js";
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
// ============================================================================
|
|
|
|
|
// Helper Schemas
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
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()]);
|
|
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
/**
|
|
|
|
|
* 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({
|
2025-10-08 10:33:33 +09:00
|
|
|
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(),
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
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(),
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-10-09 10:49:03 +09:00
|
|
|
// ============================================================================
|
|
|
|
|
// Profile Edit Schemas
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Profile edit form schema for frontend forms
|
|
|
|
|
* Contains basic editable user profile fields (WHMCS field names)
|
|
|
|
|
*/
|
|
|
|
|
export const profileEditFormSchema = z.object({
|
|
|
|
|
firstname: z.string().min(1, "First name is required").max(100).trim(),
|
|
|
|
|
lastname: z.string().min(1, "Last name is required").max(100).trim(),
|
|
|
|
|
phonenumber: z.string().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Profile display data - includes email for display (read-only)
|
|
|
|
|
* Used for displaying profile information
|
|
|
|
|
*/
|
|
|
|
|
export const profileDisplayDataSchema = profileEditFormSchema.extend({
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
// ============================================================================
|
|
|
|
|
// UserAuth Schema (Portal Database - Auth State Only)
|
|
|
|
|
// ============================================================================
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
/**
|
|
|
|
|
* 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(),
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
// ============================================================================
|
|
|
|
|
// WHMCS-Specific Schemas (Internal to Providers)
|
|
|
|
|
// ============================================================================
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
/**
|
|
|
|
|
* 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),
|
|
|
|
|
}));
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
/**
|
|
|
|
|
* 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();
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-11-04 11:14:26 +09:00
|
|
|
const whmcsRawCustomFieldsArraySchema = z.array(whmcsCustomFieldSchema);
|
2025-10-20 16:26:47 +09:00
|
|
|
|
|
|
|
|
const whmcsCustomFieldsSchema = z
|
|
|
|
|
.union([
|
|
|
|
|
z.record(z.string(), z.string()),
|
|
|
|
|
whmcsRawCustomFieldsArraySchema,
|
|
|
|
|
z
|
|
|
|
|
.object({
|
|
|
|
|
customfield: z
|
2025-11-04 11:14:26 +09:00
|
|
|
.union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema])
|
2025-10-20 16:26:47 +09:00
|
|
|
.optional(),
|
|
|
|
|
})
|
|
|
|
|
.passthrough(),
|
|
|
|
|
])
|
|
|
|
|
.optional();
|
|
|
|
|
|
|
|
|
|
const whmcsUsersSchema = z
|
|
|
|
|
.union([
|
|
|
|
|
z.array(subUserSchema),
|
|
|
|
|
z
|
|
|
|
|
.object({
|
|
|
|
|
user: z.union([subUserSchema, z.array(subUserSchema)]).optional(),
|
|
|
|
|
})
|
|
|
|
|
.passthrough(),
|
|
|
|
|
])
|
|
|
|
|
.optional();
|
|
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
*/
|
2025-11-04 11:14:26 +09:00
|
|
|
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<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),
|
|
|
|
|
};
|
|
|
|
|
});
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
// ============================================================================
|
|
|
|
|
// User Schema (API Response - Normalized camelCase)
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* User - Complete user profile for API responses
|
|
|
|
|
*
|
|
|
|
|
* Composition: UserAuth (portal DB) + WhmcsClient (WHMCS)
|
2025-10-09 10:49:03 +09:00
|
|
|
* Field names match WHMCS API exactly (no transformation)
|
2025-10-08 16:31:42 +09:00
|
|
|
*
|
|
|
|
|
* Use combineToUser() helper to construct from sources
|
|
|
|
|
*/
|
|
|
|
|
export const userSchema = userAuthSchema.extend({
|
2025-10-09 10:49:03 +09:00
|
|
|
// Profile fields (WHMCS field names - direct from API)
|
2025-10-08 16:31:42 +09:00
|
|
|
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(),
|
2025-10-09 10:49:03 +09:00
|
|
|
currency_code: z.string().nullable().optional(), // WHMCS uses snake_case for this
|
2025-10-08 16:31:42 +09:00
|
|
|
address: addressSchema.optional(),
|
2025-10-07 17:38:39 +09:00
|
|
|
});
|
|
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
// ============================================================================
|
|
|
|
|
// Helper Functions
|
|
|
|
|
// ============================================================================
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
};
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
return addressSchema.parse({
|
2025-10-07 17:38:39 +09:00
|
|
|
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),
|
|
|
|
|
});
|
2025-10-08 16:31:42 +09:00
|
|
|
}
|
2025-10-07 17:38:39 +09:00
|
|
|
|
2025-10-09 10:49:03 +09:00
|
|
|
/**
|
|
|
|
|
* Convert profile form data to update request format
|
|
|
|
|
* No transformation needed - form already uses WHMCS field names
|
|
|
|
|
*/
|
|
|
|
|
export function profileFormToRequest(form: ProfileEditFormData): {
|
|
|
|
|
firstname: string;
|
|
|
|
|
lastname: string;
|
|
|
|
|
phonenumber?: string;
|
|
|
|
|
} {
|
|
|
|
|
return {
|
|
|
|
|
firstname: form.firstname.trim(),
|
|
|
|
|
lastname: form.lastname.trim(),
|
|
|
|
|
phonenumber: form.phonenumber?.trim() || undefined,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
/**
|
|
|
|
|
* Combine UserAuth and WhmcsClient into User
|
|
|
|
|
*
|
|
|
|
|
* This is the single source of truth for constructing User from its sources.
|
2025-10-09 10:49:03 +09:00
|
|
|
* No field name transformation - User schema uses WHMCS field names directly.
|
2025-10-08 16:31:42 +09:00
|
|
|
*
|
|
|
|
|
* @param userAuth - Authentication state from portal database
|
|
|
|
|
* @param whmcsClient - Full client data from WHMCS
|
2025-10-09 10:49:03 +09:00
|
|
|
* @returns User object for API responses with WHMCS field names
|
2025-10-08 16:31:42 +09:00
|
|
|
*/
|
|
|
|
|
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,
|
|
|
|
|
|
2025-10-09 10:49:03 +09:00
|
|
|
// Profile from WHMCS (no transformation - keep field names as-is)
|
2025-10-08 16:31:42 +09:00
|
|
|
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,
|
2025-10-09 10:49:03 +09:00
|
|
|
currency_code: whmcsClient.currency_code || null,
|
2025-10-08 16:31:42 +09:00
|
|
|
address: whmcsClient.address || undefined,
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-08 10:33:33 +09:00
|
|
|
|
|
|
|
|
// ============================================================================
|
2025-10-08 16:31:42 +09:00
|
|
|
// Exported Types (Public API)
|
2025-10-08 10:33:33 +09:00
|
|
|
// ============================================================================
|
|
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
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>;
|
2025-10-08 10:33:33 +09:00
|
|
|
export type AddressFormData = z.infer<typeof addressFormSchema>;
|
2025-10-09 10:49:03 +09:00
|
|
|
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
|
2025-10-20 13:53:35 +09:00
|
|
|
export type ProfileDisplayData = z.infer<typeof profileDisplayDataSchema>;
|
2025-10-09 10:49:03 +09:00
|
|
|
|
|
|
|
|
// Convenience aliases
|
|
|
|
|
export type UserProfile = User; // Alias for user profile
|
|
|
|
|
export type AuthenticatedUser = User; // Alias for authenticated user context
|
2025-10-08 10:33:33 +09:00
|
|
|
|
2025-10-08 16:31:42 +09:00
|
|
|
// ============================================================================
|
|
|
|
|
// 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 };
|