/** * Address Domain - Schemas * * Zod validation schemas for address lookup and bilingual address data. * Types are derived from schemas (Schema-First Approach). */ import { z } from "zod"; import { truncate } from "../toolkit/formatting/text.js"; import { ADDRESS_INPUT_LIMITS, STREET_ADDRESS_PATTERN, WHMCS_ADDRESS_LIMITS } from "./constants.js"; // ============================================================================ // ZIP Code Schemas // ============================================================================ /** * Japanese ZIP code schema * Accepts: "1000001", "100-0001" -> normalizes to "1000001" */ export const zipCodeSchema = z .string() .regex(/^\d{3}-?\d{4}$/, "ZIP code must be 7 digits (e.g., 100-0001)") .transform(val => val.replace(/-/g, "")); /** * ZIP code lookup request */ export const zipCodeLookupRequestSchema = z.object({ zipCode: zipCodeSchema, }); // ============================================================================ // Japan Post Address Schemas // ============================================================================ /** * Address from Japan Post API lookup * Contains both Japanese and romanized versions */ export const japanPostAddressSchema = z.object({ zipCode: z.string(), // Japanese (for Salesforce) prefecture: z.string(), prefectureKana: z.string().optional(), city: z.string(), cityKana: z.string().optional(), town: z.string(), townKana: z.string().optional(), // Romanized (for WHMCS) prefectureRoma: z.string(), cityRoma: z.string(), townRoma: z.string(), }); /** * Address lookup result containing multiple potential matches */ export const addressLookupResultSchema = z.object({ zipCode: z.string(), addresses: z.array(japanPostAddressSchema), count: z.number(), }); // ============================================================================ // Street Address Detail (Chome/Banchi/Go) // ============================================================================ /** * Street address detail schema (chome/banchi/go) * Only accepts hyphenated number format: "1-5-3", "1-5", "15-3" * * Format: {chome}-{banchi}-{go} or {chome}-{banchi} * - chome: Block district number (1-99) * - banchi: Block number (1-999) * - go: Building/house number (1-999, optional) */ export const streetAddressDetailSchema = z .string() .min(1, "Street address is required") .max(20, "Street address is too long") .regex( new RegExp(`^${STREET_ADDRESS_PATTERN.source}$`), "Use format like 1-5-3 (chome-banchi-go) or 1-5 (chome-banchi)" ); // ============================================================================ // Bilingual Address Schemas (Extended from customer/addressSchema) // ============================================================================ /** * Building information for Japanese addresses. * Field lengths are constrained to ensure composed values fit within WHMCS limits. */ export const buildingInfoSchema = z.object({ buildingName: z.string().max(ADDRESS_INPUT_LIMITS.BUILDING_NAME_MAX).optional().nullable(), roomNumber: z.string().max(ADDRESS_INPUT_LIMITS.ROOM_NUMBER_MAX).optional().nullable(), residenceType: z.enum(["house", "apartment"]), }); /** * Extended address data with bilingual fields * Used when updating address in both WHMCS (English) and Salesforce (Japanese) * * Field lengths are constrained to ensure composed values fit within WHMCS limits. * @see ADDRESS_INPUT_LIMITS in constants.ts for the calculated limits */ export const bilingualAddressSchema = z.object({ // ZIP code postcode: z.string(), // English/Romanized (for WHMCS) prefecture: z.string(), // romanized city: z.string(), // romanized town: z.string(), // romanized // Japanese (for Salesforce) prefectureJa: z.string(), cityJa: z.string(), townJa: z.string(), // Street address detail (chome/banchi/go) - e.g., "1-5-3" or "1丁目5番3号" streetAddress: streetAddressDetailSchema, // Building info (same for both systems) // Limits ensure address1 (building + room) fits in WHMCS 200-char limit buildingName: z.string().max(ADDRESS_INPUT_LIMITS.BUILDING_NAME_MAX).optional().nullable(), roomNumber: z.string().max(ADDRESS_INPUT_LIMITS.ROOM_NUMBER_MAX).optional().nullable(), residenceType: z.enum(["house", "apartment"]), }); /** * Address update request for profile/signup * Combines bilingual address data for dual-write to WHMCS + Salesforce */ export const addressUpdateRequestSchema = bilingualAddressSchema.extend({ country: z.literal("JP").default("JP"), countryCode: z.literal("JP").default("JP"), }); // ============================================================================ // WHMCS Address Mapping // ============================================================================ /** * Prepare address fields for WHMCS update * Maps bilingual address to WHMCS field format * * WHMCS field mapping: * - address1: Building name + Room number (e.g., "Sunshine Mansion 101") * - address2: Town + Street address (e.g., "Higashiazabu 1-5-3") * - city: City (romanized) * - state: Prefecture (romanized) * * Includes safety truncation to ensure fields fit WHMCS limits. * This should rarely trigger if input validation works correctly. */ export function prepareWhmcsAddressFields(address: BilingualAddress): WhmcsAddressFields { const buildingPart = address.buildingName || ""; const roomPart = address.roomNumber || ""; // address1: Building + Room (for apartments) or just Building (for houses) let address1 = address.residenceType === "apartment" ? `${buildingPart} ${roomPart}`.trim() : buildingPart; // address2: Town + Street address (romanized) let address2 = `${address.town} ${address.streetAddress}`.trim(); // Safety truncation - should rarely trigger if input validation is enforced // Using empty suffix to avoid adding "..." which could confuse address parsing if (address1.length > WHMCS_ADDRESS_LIMITS.ADDRESS1_MAX) { address1 = truncate(address1, WHMCS_ADDRESS_LIMITS.ADDRESS1_MAX, ""); } if (address2.length > WHMCS_ADDRESS_LIMITS.ADDRESS2_MAX) { address2 = truncate(address2, WHMCS_ADDRESS_LIMITS.ADDRESS2_MAX, ""); } return { address1: address1 || undefined, address2: address2 || undefined, city: address.city, // romanized city state: address.prefecture, // romanized prefecture postcode: address.postcode, country: "JP", countrycode: "JP", }; } // ============================================================================ // Salesforce Contact Address Mapping // ============================================================================ /** * Prepare address fields for Salesforce Contact update * Maps bilingual address to Salesforce field format * * Salesforce field mapping: * - MailingStreet: Town + Street address (Japanese) * - MailingCity: City (Japanese) * - MailingState: Prefecture (Japanese) */ export function prepareSalesforceContactAddressFields( address: BilingualAddress ): SalesforceContactAddressFields { // Combine town and street address for MailingStreet // Example: "東麻布1-5-3" or "東麻布1丁目5番3号" const mailingStreet = `${address.townJa}${address.streetAddress}`; return { MailingStreet: mailingStreet, MailingCity: address.cityJa, // Japanese city MailingState: address.prefectureJa, // Japanese prefecture MailingPostalCode: address.postcode, MailingCountry: "Japan", BuildingName__c: address.buildingName || null, RoomNumber__c: address.roomNumber || null, }; } // ============================================================================ // Exported Types // ============================================================================ export type ZipCode = z.input; export type ZipCodeLookupRequest = z.infer; export type JapanPostAddress = z.infer; export type AddressLookupResult = z.infer; export type StreetAddressDetail = z.infer; export type BuildingInfo = z.infer; export type BilingualAddress = z.infer; export type AddressUpdateRequest = z.infer; /** * WHMCS address field structure */ export interface WhmcsAddressFields { address1?: string | undefined; address2?: string | undefined; city: string; state: string; postcode: string; country: string; countrycode: string; } /** * Salesforce Contact address field structure */ export interface SalesforceContactAddressFields { MailingStreet: string; MailingCity: string; MailingState: string; MailingPostalCode: string; MailingCountry: string; BuildingName__c: string | null; RoomNumber__c: string | null; }