From 55489cad2025625dd61341f230f6d40a3bb6d3d4 Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 8 Oct 2025 16:31:42 +0900 Subject: [PATCH] Refactor WHMCS integration and user management to align with updated domain structure. Removed deprecated validation utilities and types, enhancing type safety and maintainability. Streamlined import paths and module exports for consistency, ensuring clear separation of concerns in data handling. Updated user and address management to reflect new schemas, improving validation and organization across the application. --- AUTH-SCHEMA-IMPROVEMENTS.md | 249 ++++++++ CUSTOMER-DOMAIN-CLEANUP.md | 116 ++++ CUSTOMER-DOMAIN-FINAL-CLEANUP.md | 177 ++++++ CUSTOMER-DOMAIN-NAMING.md | 255 +++++++++ CUSTOMER-DOMAIN-REFACTOR.md | 541 ++++++++++++++++++ INVOICE-VALIDATION-CLEANUP-PLAN.md | 167 ++++++ VALIDATION-CLEANUP-COMPLETE.md | 157 +++++ apps/bff/src/core/utils/validation.util.ts | 25 - apps/bff/src/infra/mappers/user.mapper.ts | 49 +- .../whmcs/cache/whmcs-cache.service.ts | 9 +- .../services/whmcs-api-methods.service.ts | 10 +- .../whmcs-connection-orchestrator.service.ts | 10 +- .../whmcs/services/whmcs-client.service.ts | 13 +- .../whmcs/services/whmcs-invoice.service.ts | 4 +- .../whmcs/services/whmcs-payment.service.ts | 6 +- .../whmcs/services/whmcs-sso.service.ts | 2 +- .../services/whmcs-subscription.service.ts | 2 +- .../whmcs/types/whmcs-api.types.ts | 132 ----- .../src/integrations/whmcs/whmcs.service.ts | 24 +- .../modules/auth/application/auth.facade.ts | 4 +- apps/bff/src/modules/auth/auth.types.ts | 4 +- .../modules/auth/infra/token/token.service.ts | 5 +- .../workflows/password-workflow.service.ts | 18 +- .../workflows/signup-workflow.service.ts | 8 +- .../workflows/whmcs-link-workflow.service.ts | 14 +- .../presentation/strategies/jwt.strategy.ts | 4 +- .../cache/mapping-cache.service.ts | 2 +- .../modules/id-mappings/mappings.service.ts | 4 +- .../id-mappings/types/mapping.types.ts | 58 +- .../validation/mapping-validator.service.ts | 191 ++----- apps/bff/src/modules/invoices/index.ts | 27 +- .../modules/invoices/invoices.controller.ts | 35 +- .../src/modules/invoices/invoices.module.ts | 9 +- .../services/invoice-health.service.ts | 2 +- .../services/invoice-retrieval.service.ts | 78 +-- .../services/invoices-orchestrator.service.ts | 52 +- .../types/invoice-monitoring.types.ts | 27 + .../invoices/types/invoice-service.types.ts | 41 -- .../validators/invoice-validator.service.ts | 176 ------ .../order-fulfillment-orchestrator.service.ts | 316 +--------- .../order-fulfillment-validator.service.ts | 10 +- .../services/order-validator.service.ts | 4 +- .../services/sim-fulfillment.service.ts | 6 +- .../modules/orders/types/fulfillment.types.ts | 11 - .../subscriptions/sim-management.service.ts | 2 +- .../subscriptions/sim-management/index.ts | 4 +- .../services/sim-cancellation.service.ts | 2 +- .../services/sim-orchestrator.service.ts | 2 +- .../services/sim-plan.service.ts | 2 +- .../services/sim-topup.service.ts | 2 +- .../services/sim-usage.service.ts | 2 +- .../types/sim-requests.types.ts | 15 - .../subscriptions/subscriptions.controller.ts | 20 +- .../subscriptions/subscriptions.service.ts | 4 +- apps/bff/src/modules/users/users.service.ts | 60 +- bff-validation-migration.md | 137 +++++ docs/VALIDATION_PATTERNS.md | 413 +++++++++++++ packages/domain/auth/contract.ts | 83 ++- packages/domain/auth/index.ts | 69 ++- packages/domain/auth/schema.ts | 83 ++- packages/domain/billing/index.ts | 10 +- .../billing/providers/whmcs/raw.types.ts | 88 ++- packages/domain/common/index.ts | 1 + packages/domain/common/validation.ts | 98 ++++ packages/domain/customer/contract.ts | 54 +- packages/domain/customer/index.ts | 95 ++- packages/domain/customer/providers/index.ts | 12 +- .../domain/customer/providers/portal/index.ts | 9 + .../customer/providers/portal/mapper.ts | 31 + .../domain/customer/providers/portal/types.ts | 27 + .../domain/customer/providers/whmcs/index.ts | 23 +- .../domain/customer/providers/whmcs/mapper.ts | 229 ++------ .../customer/providers/whmcs/raw.types.ts | 53 ++ packages/domain/customer/schema.ts | 429 ++++++++------ packages/domain/mappings/contract.ts | 9 + packages/domain/mappings/index.ts | 10 + packages/domain/mappings/schema.ts | 58 ++ packages/domain/mappings/validation.ts | 203 +++++++ packages/domain/orders/contract.ts | 6 +- packages/domain/orders/index.ts | 4 - .../domain/orders/providers/whmcs/mapper.ts | 56 +- packages/domain/orders/schema.ts | 32 -- packages/domain/orders/validation.ts | 31 +- packages/domain/package.json | 2 + packages/domain/payments/index.ts | 5 +- .../payments/providers/whmcs/raw.types.ts | 21 + packages/domain/subscriptions/index.ts | 8 +- .../providers/whmcs/raw.types.ts | 27 +- packages/domain/subscriptions/schema.ts | 36 ++ 89 files changed, 3866 insertions(+), 1755 deletions(-) create mode 100644 AUTH-SCHEMA-IMPROVEMENTS.md create mode 100644 CUSTOMER-DOMAIN-CLEANUP.md create mode 100644 CUSTOMER-DOMAIN-FINAL-CLEANUP.md create mode 100644 CUSTOMER-DOMAIN-NAMING.md create mode 100644 CUSTOMER-DOMAIN-REFACTOR.md create mode 100644 INVOICE-VALIDATION-CLEANUP-PLAN.md create mode 100644 VALIDATION-CLEANUP-COMPLETE.md delete mode 100644 apps/bff/src/core/utils/validation.util.ts delete mode 100644 apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts create mode 100644 apps/bff/src/modules/invoices/types/invoice-monitoring.types.ts delete mode 100644 apps/bff/src/modules/invoices/types/invoice-service.types.ts delete mode 100644 apps/bff/src/modules/invoices/validators/invoice-validator.service.ts delete mode 100644 apps/bff/src/modules/orders/types/fulfillment.types.ts delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/types/sim-requests.types.ts create mode 100644 bff-validation-migration.md create mode 100644 docs/VALIDATION_PATTERNS.md create mode 100644 packages/domain/common/validation.ts create mode 100644 packages/domain/customer/providers/portal/index.ts create mode 100644 packages/domain/customer/providers/portal/mapper.ts create mode 100644 packages/domain/customer/providers/portal/types.ts create mode 100644 packages/domain/mappings/validation.ts diff --git a/AUTH-SCHEMA-IMPROVEMENTS.md b/AUTH-SCHEMA-IMPROVEMENTS.md new file mode 100644 index 00000000..89e82136 --- /dev/null +++ b/AUTH-SCHEMA-IMPROVEMENTS.md @@ -0,0 +1,249 @@ +# Auth Schema Improvements - Explanation + +## Problems Identified and Fixed + +### 1. **`z.unknown()` Type Safety Issue** ❌ → ✅ + +**Problem:** +```typescript +// BEFORE - NO TYPE SAFETY +export const signupResultSchema = z.object({ + user: z.unknown(), // ❌ Loses all type information and validation + tokens: authTokensSchema, +}); +``` + +**Why it was wrong:** +- `z.unknown()` provides **zero validation** at runtime +- TypeScript can't infer proper types from it +- Defeats the purpose of schema-first architecture +- Any object could pass validation, even malformed data + +**Solution:** +```typescript +// AFTER - FULL TYPE SAFETY +export const userProfileSchema = z.object({ + id: z.string().uuid(), + email: z.string().email(), + role: z.enum(["USER", "ADMIN"]), + // ... all fields properly typed +}); + +export const signupResultSchema = z.object({ + user: userProfileSchema, // ✅ Full validation and type inference + tokens: authTokensSchema, +}); +``` + +**Benefits:** +- Runtime validation of user data structure +- Proper TypeScript type inference +- Catches invalid data early +- Self-documenting API contract + +--- + +### 2. **Email Validation Issues** ❌ → ✅ + +**Problem:** +The `z.string().email()` in `checkPasswordNeededResponseSchema` was correct, but using `z.unknown()` elsewhere meant emails weren't being validated in user objects. + +**Solution:** +```typescript +export const userProfileSchema = z.object({ + email: z.string().email(), // ✅ Validates email format + // ... +}); +``` + +Now all user objects have their emails properly validated. + +--- + +### 3. **UserProfile Alias Confusion** 🤔 → ✅ + +**Problem:** +```typescript +export type UserProfile = AuthenticatedUser; +``` + +This created confusion about why we have two names for the same thing. + +**Solution - Added Clear Documentation:** +```typescript +/** + * UserProfile type alias + * + * Note: This is an alias for backward compatibility. + * Both types represent the same thing: a complete user profile with auth state. + * + * Architecture: + * - PortalUser: Only auth state from portal DB (id, email, role, emailVerified, etc.) + * - CustomerProfile: Profile data from WHMCS (firstname, lastname, address, etc.) + * - AuthenticatedUser/UserProfile: CustomerProfile + auth state = complete user + */ +export type UserProfile = AuthenticatedUser; +``` + +**Why the alias exists:** +- **Backward compatibility**: Code may use either name +- **Domain language**: "UserProfile" is more business-friendly than "AuthenticatedUser" +- **Convention**: Many authentication libraries use "UserProfile" + +--- + +## Architecture Explanation + +### Three-Layer User Model + +``` +┌─────────────────────────────────────────────┐ +│ AuthenticatedUser / UserProfile │ +│ (Complete User) │ +│ ┌────────────────────────────────────────┐ │ +│ │ CustomerProfile (WHMCS) │ │ +│ │ - firstname, lastname │ │ +│ │ - address, phone │ │ +│ │ - language, currency │ │ +│ └────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────┐ │ +│ │ PortalUser (Portal DB) │ │ +│ │ - id, email │ │ +│ │ - role, emailVerified, mfaEnabled │ │ +│ │ - lastLoginAt │ │ +│ └────────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +### 1. **PortalUser** (Portal Database) +- **Purpose**: Authentication state only +- **Source**: Portal's Prisma database +- **Fields**: id, email, role, emailVerified, mfaEnabled, lastLoginAt +- **Schema**: `portalUserSchema` +- **Use Cases**: JWT validation, token refresh, auth checks + +### 2. **CustomerProfile** (WHMCS) +- **Purpose**: Business profile data +- **Source**: WHMCS API (single source of truth for profile data) +- **Fields**: firstname, lastname, address, phone, language, currency +- **Schema**: Interface only (external system) +- **Use Cases**: Displaying user info, profile updates + +### 3. **AuthenticatedUser / UserProfile** (Combined) +- **Purpose**: Complete user representation +- **Source**: Portal DB + WHMCS (merged) +- **Fields**: All fields from PortalUser + CustomerProfile +- **Schema**: `userProfileSchema` (for validation) +- **Use Cases**: API responses, full user context + +--- + +## Why This Matters + +### Before (with `z.unknown()`): +```typescript +// Could pass completely invalid data ❌ +const badData = { + user: { totally: "wrong", structure: true }, + tokens: { /* ... */ } +}; +// Would validate successfully! ❌ +``` + +### After (with proper schema): +```typescript +// Validates structure correctly ✅ +const goodData = { + user: { + id: "uuid-here", + email: "valid@email.com", + role: "USER", + // ... all required fields + }, + tokens: { /* ... */ } +}; +// Validates ✅ + +const badData = { + user: { wrong: "structure" }, + tokens: { /* ... */ } +}; +// Throws validation error ✅ +``` + +--- + +## Implementation Details + +### Schema Definition Location + +The `userProfileSchema` is defined **before** it's used: + +```typescript +// 1. First, define the schema +export const userProfileSchema = z.object({ + // All fields defined +}); + +// 2. Then use it in response schemas +export const authResponseSchema = z.object({ + user: userProfileSchema, // ✅ Now defined + tokens: authTokensSchema, +}); +``` + +### Type Inference + +TypeScript now properly infers types: + +```typescript +// Type is inferred from schema +type InferredUser = z.infer; + +// Results in proper type: +// { +// id: string; +// email: string; +// role: "USER" | "ADMIN"; +// emailVerified: boolean; +// // ... etc +// } +``` + +--- + +## Migration Impact + +### ✅ No Breaking Changes + +- Existing code continues to work +- `UserProfile` and `AuthenticatedUser` still exist +- Only **adds** validation, doesn't remove functionality + +### ✅ Improved Type Safety + +- Better autocomplete in IDEs +- Compile-time error checking +- Runtime validation of API responses + +### ✅ Better Documentation + +- Schema serves as living documentation +- Clear separation of concerns +- Self-describing data structures + +--- + +## Summary + +| Issue | Before | After | +|-------|--------|-------| +| User validation | `z.unknown()` ❌ | `userProfileSchema` ✅ | +| Type safety | None | Full | +| Runtime validation | None | Complete | +| Documentation | Unclear | Self-documenting | +| Email validation | Partial | Complete | +| UserProfile clarity | Confusing | Documented | + +**Result**: Proper schema-first architecture with full type safety and validation! 🎉 + diff --git a/CUSTOMER-DOMAIN-CLEANUP.md b/CUSTOMER-DOMAIN-CLEANUP.md new file mode 100644 index 00000000..3cc71cba --- /dev/null +++ b/CUSTOMER-DOMAIN-CLEANUP.md @@ -0,0 +1,116 @@ +# Customer Domain Cleanup Plan + +## Problems Identified + +### 1. Too Many Aliases +```typescript +// Current mess: +User, UserProfile, AuthenticatedUser // 3 names for same thing! +UserAuth, PortalUser // 2 names for same thing! +CustomerAddress, Address // 2 names for same thing! +CustomerData, CustomerProfile // CustomerProfile is manually defined, not from schema! +``` + +### 2. Manual Interface (BAD) +```typescript +// This bypasses schema-first approach! +export interface CustomerProfile { + id: string; + email: string; + // ... manually duplicated fields +} +``` + +### 3. Unnecessary Schema Type Exports +```typescript +export type CustomerAddressSchema = typeof customerAddressSchema; // Unused +export type CustomerSchema = typeof customerSchema; // Unused +// ... etc +``` + +### 4. Confusing Naming +- `Customer` = Full WHMCS customer schema (internal use only) +- `CustomerData` = Profile subset +- `CustomerProfile` = Duplicate manual interface (deprecated) + +## Cleanup Plan + +### Keep Only These Core Types: + +```typescript +// === User Entity Types === +UserAuth // Auth state from portal DB +User // Complete user (auth + profile) + +// === WHMCS Data Types === +CustomerAddress // Address structure +CustomerData // Profile data from WHMCS (internal provider use) + +// === Supporting Types === +CustomerEmailPreferences +CustomerUser // Sub-users +CustomerStats // Billing stats +AddressFormData // Form validation + +// === Internal Provider Types === +Customer // Full WHMCS schema (only used in WHMCS mapper) + +// === Role === +UserRole +``` + +### Remove These: +- ❌ `PortalUser` (use `UserAuth`) +- ❌ `UserProfile` (use `User`) +- ❌ `AuthenticatedUser` (use `User`) +- ❌ `CustomerProfile` interface (use `User` or `CustomerData`) +- ❌ `Address` alias (use `CustomerAddress`) +- ❌ All schema type exports (`CustomerAddressSchema`, etc.) +- ❌ `addressSchema` const alias (use `customerAddressSchema`) + +## Implementation + +### 1. Clean types.ts +Remove: +- All deprecated alias types +- Manual `CustomerProfile` interface +- Schema type exports +- `addressSchema` alias + +### 2. Clean index.ts +Export only: +- Core types: `User`, `UserAuth`, `UserRole` +- Supporting types: `CustomerAddress`, `CustomerData`, `CustomerEmailPreferences`, `CustomerUser`, `CustomerStats`, `AddressFormData` +- Internal: `Customer` (for WHMCS mapper only) + +### 3. Update BFF imports (only 9 occurrences!) +Replace any remaining: +- `CustomerProfile` → `User` +- Other deprecated → proper type + +## Result: Clean & Clear + +```typescript +// Customer Domain Exports (CLEAN!) + +// === User Types === +User // Complete user for APIs +UserAuth // Auth state for JWT +UserRole // "USER" | "ADMIN" + +// === WHMCS Types === +CustomerAddress // Address structure +CustomerData // Profile data (internal) + +// === Supporting === +CustomerEmailPreferences +CustomerUser +CustomerStats +AddressFormData + +// === Internal === +Customer // Full WHMCS schema (mapper only) +``` + +**No aliases. No confusion. Schema-first.** + diff --git a/CUSTOMER-DOMAIN-FINAL-CLEANUP.md b/CUSTOMER-DOMAIN-FINAL-CLEANUP.md new file mode 100644 index 00000000..5949fd44 --- /dev/null +++ b/CUSTOMER-DOMAIN-FINAL-CLEANUP.md @@ -0,0 +1,177 @@ +# Customer Domain - Final Cleanup + +## Problems Identified + +### 1. Unnecessary Infra Mapper (BFF) +**Current:** +``` +BFF: @prisma/client User → user.mapper.ts → domain/customer/providers/portal → UserAuth +``` + +**Problem:** Double mapping! BFF adapter just converts Prisma type to domain raw type, then calls domain mapper. + +**Better:** +``` +BFF: @prisma/client User → directly use domain/customer/providers/portal/mapper → UserAuth +``` + +The BFF mapper is redundant - we should use the domain mapper directly. + +### 2. Unused Legacy WHMCS Types + +**Never used in BFF:** +- ❌ `CustomerEmailPreferences` (0 occurrences) +- ❌ `CustomerUser` (sub-users, 0 occurrences) +- ❌ `CustomerStats` (billing stats, 0 occurrences) +- ❌ `Customer` (full WHMCS schema - only used internally in WHMCS mapper) + +**Schemas for unused types:** +- ❌ `customerEmailPreferencesSchema` (~40 lines) +- ❌ `customerUserSchema` (~25 lines) +- ❌ `customerStatsSchema` (~50 lines) +- ❌ `customerSchema` (~35 lines) + +**Total bloat:** ~150 lines of unused code! + +### 3. Not Following Schema-First in Domain Mapper + +**Current:** `packages/domain/customer/providers/portal/mapper.ts` +```typescript +// Manually constructs object - NO SCHEMA! +export function mapPrismaUserToUserAuth(raw: PrismaUserRaw): UserAuth { + return { + id: raw.id, + email: raw.email, + // ... manual construction + }; +} +``` + +**Should be:** +```typescript +// Use schema for validation! +export function mapPrismaUserToUserAuth(raw: PrismaUserRaw): UserAuth { + return userAuthSchema.parse({ + id: raw.id, + email: raw.email, + // ... + }); +} +``` + +## Cleanup Plan + +### Step 1: Remove Unused Types + +**File:** `packages/domain/customer/types.ts` + +Remove: +- `customerEmailPreferencesSchema` + type +- `customerUserSchema` + type +- `customerStatsSchema` + type +- `customerSchema` + type (full WHMCS schema) +- Helper functions for these types + +**Keep only:** +- `userAuthSchema` → `UserAuth` +- `userSchema` → `User` +- `customerDataSchema` → `CustomerData` +- `customerAddressSchema` → `CustomerAddress` +- `addressFormSchema` → `AddressFormData` + +### Step 2: Update Customer Index Exports + +**File:** `packages/domain/customer/index.ts` + +Remove exports: +- `CustomerEmailPreferences` +- `CustomerUser` +- `CustomerStats` +- `Customer` +- All related schemas + +### Step 3: Clean WHMCS Mapper (Internal Use Only) + +**File:** `packages/domain/customer/providers/whmcs/mapper.ts` + +Since `Customer` type is removed, the WHMCS mapper can: +- Either define types inline (internal to mapper) +- Or export minimal `WhmcsCustomer` type just for mapper use + +### Step 4: Use Schema in Portal Mapper + +**File:** `packages/domain/customer/providers/portal/mapper.ts` + +```typescript +import { userAuthSchema } from "../../types"; +import type { PrismaUserRaw } from "./types"; +import type { UserAuth } from "../../types"; + +export function mapPrismaUserToUserAuth(raw: PrismaUserRaw): UserAuth { + return userAuthSchema.parse({ + id: raw.id, + email: raw.email, + role: raw.role, + mfaEnabled: !!raw.mfaSecret, + emailVerified: raw.emailVerified, + lastLoginAt: raw.lastLoginAt?.toISOString(), + createdAt: raw.createdAt.toISOString(), + updatedAt: raw.updatedAt.toISOString(), + }); +} +``` + +### Step 5: Eliminate BFF Infra Mapper + +**Delete:** `apps/bff/src/infra/mappers/user.mapper.ts` + +**Update all 7 usages** to directly import from domain: + +```typescript +// OLD (double mapping - BAD!) +import { mapPrismaUserToDomain } from "@bff/infra/mappers"; +const userAuth = mapPrismaUserToDomain(prismaUser); + +// NEW (direct - GOOD!) +import { mapPrismaUserToUserAuth } from "@customer-portal/domain/customer/providers/portal"; +const userAuth = mapPrismaUserToUserAuth(prismaUser); +``` + +Files to update (7 total): +1. `apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts` +2. `apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts` +3. `apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts` +4. `apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts` +5. `apps/bff/src/modules/auth/infra/token/token.service.ts` +6. `apps/bff/src/modules/auth/application/auth.facade.ts` +7. Any other imports of `mapPrismaUserToDomain` + +## Result + +**Before:** +- 342 lines in types.ts (lots of unused code) +- BFF infra mapper (unnecessary layer) +- Manual object construction (no schema validation) + +**After:** +- ~190 lines in types.ts (lean, only what's used) +- Direct domain mapper usage (proper architecture) +- Schema-validated mapping (type safe + runtime safe) + +**Types kept:** +```typescript +// User entity types +User // Complete user +UserAuth // Auth state +UserRole // Role enum + +// Address +CustomerAddress // Address structure +AddressFormData // Form validation + +// Profile data (internal) +CustomerData // WHMCS profile fields +``` + +**Clean, minimal, schema-first! 🎯** + diff --git a/CUSTOMER-DOMAIN-NAMING.md b/CUSTOMER-DOMAIN-NAMING.md new file mode 100644 index 00000000..7c7dff98 --- /dev/null +++ b/CUSTOMER-DOMAIN-NAMING.md @@ -0,0 +1,255 @@ +# Customer Domain - Type Naming Clarity + +## The Problem + +Current naming is confusing: +- `PortalUser` - What is this? +- `CustomerProfile` - Is this different from a user? +- `UserProfile` - How is this different from the above? +- `AuthenticatedUser` - Another alias? + +## Proposed Clear Naming + +### Core Principle: Same Entity, Different Data Sources + +A customer/user is **ONE business entity** with data from **TWO sources**: + +1. **Portal Database** → Authentication & account state +2. **WHMCS** → Profile & billing information + +## Recommended Schema Structure + +```typescript +// packages/domain/customer/types.ts + +// ============================================================================ +// User Auth State (from Portal Database) +// ============================================================================ + +/** + * User authentication and account state stored in portal database + * Source: Portal DB (Prisma) + * Contains: Auth-related fields only + */ +export const userAuthSchema = z.object({ + id: z.string().uuid(), // Portal user ID (primary key) + email: z.string().email(), // Email (for login) + role: z.enum(["USER", "ADMIN"]), // User role + emailVerified: z.boolean(), // Email verification status + mfaEnabled: z.boolean(), // MFA enabled flag + lastLoginAt: z.string().optional(), // Last login timestamp + createdAt: z.string(), // Account created date + updatedAt: z.string(), // Account updated date +}); + +export type UserAuth = z.infer; + + +// ============================================================================ +// Customer Profile Data (from WHMCS) +// ============================================================================ + +/** + * Customer profile and billing information from WHMCS + * Source: WHMCS API + * Contains: Personal info, address, preferences + */ +export const customerDataSchema = z.object({ + whmcsClientId: z.number().int(), // WHMCS client ID + 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(), + address: customerAddressSchema.optional(), + language: z.string().nullable().optional(), + currencyCode: z.string().nullable().optional(), + // ... other WHMCS fields +}); + +export type CustomerData = z.infer; + + +// ============================================================================ +// Complete User (Auth + Profile Data) +// ============================================================================ + +/** + * Complete user profile combining auth state and customer data + * Composition: UserAuth (portal) + CustomerData (WHMCS) + * This is what gets returned in API responses + */ +export const userSchema = userAuthSchema.extend({ + // Add customer data fields + 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(), + address: customerAddressSchema.optional(), + language: z.string().nullable().optional(), + currencyCode: z.string().nullable().optional(), +}); + +/** + * Complete user - exported as 'User' for simplicity + * Use this in API responses and business logic + */ +export type User = z.infer; +``` + +## Naming Comparison + +### Option A: Auth/Data (Recommended) +```typescript +UserAuth // Auth state from portal DB +CustomerData // Profile data from WHMCS +User // Complete entity +``` + +**Pros:** +- Clear distinction: "Auth" = authentication, "Data" = profile information +- Simple exports: `User` is the main type +- Intuitive: "I need UserAuth for JWT" vs "I need User for profile API" + +**Cons:** +- "CustomerData" might seem weird (but it's accurate - it's WHMCS customer data) + +### Option B: Portal/Customer (Current-ish) +```typescript +PortalUser // Auth state from portal DB +CustomerProfile // Profile data from WHMCS +User // Complete entity +``` + +**Pros:** +- "Portal" clearly indicates source +- "CustomerProfile" is business-domain language + +**Cons:** +- "PortalUser" vs "User" - what's the difference? +- Implies there are different "user" entities (there's only ONE user) + +### Option C: Auth/Profile (Simple) +```typescript +AuthUser // Auth state +ProfileData // Profile info +User // Complete entity +``` + +**Pros:** +- Very simple and clear +- Purpose-driven naming + +**Cons:** +- Less obvious where data comes from + +### Option D: Keep Descriptive (Verbose but Clear) +```typescript +UserAuthState // Auth state from portal DB +CustomerProfileData // Profile data from WHMCS +UserWithProfile // Complete entity +``` + +**Pros:** +- Extremely explicit +- No ambiguity + +**Cons:** +- Verbose +- "UserWithProfile" is awkward + +## Recommended: Option A + +```typescript +// Clear and intuitive +import type { UserAuth, CustomerData, User } from "@customer-portal/domain/customer"; + +// In JWT strategy - only need auth state +async validate(payload: JwtPayload): Promise { + return this.getAuthState(payload.sub); +} + +// In profile API - need complete user +async getProfile(userId: string): Promise { + const auth = await this.getAuthState(userId); + const data = await this.getCustomerData(userId); + + return { + ...auth, + ...data, + }; +} +``` + +## Provider Structure + +``` +packages/domain/customer/providers/ +├── portal/ +│ ├── types.ts # PrismaUserRaw interface +│ └── mapper.ts # Prisma → UserAuth +│ +└── whmcs/ + ├── types.ts # WhmcsClientRaw interface + └── mapper.ts # WHMCS → CustomerData +``` + +## What Gets Exported + +```typescript +// packages/domain/customer/index.ts + +export type { + // Core types (use these in your code) + User, // Complete user (UserAuth + CustomerData) + UserAuth, // Auth state only (for JWT, auth checks) + CustomerData, // Profile data only (rarely used directly) + + // Supporting types + CustomerAddress, + // ... etc +} from "./types"; + +export { + // Schemas for validation + userSchema, + userAuthSchema, + customerDataSchema, + // ... etc +} from "./types"; + +export * as Providers from "./providers"; +``` + +## Auth Domain References User + +```typescript +// packages/domain/auth/types.ts + +import { userSchema } from "../customer/types"; + +export const authResponseSchema = z.object({ + user: userSchema, // Reference the User schema from customer domain + tokens: authTokensSchema, +}); + +export type AuthResponse = z.infer; +``` + +## Summary + +**Entity**: ONE user/customer entity + +**Data Sources**: +- `UserAuth` - From portal database (auth state) +- `CustomerData` - From WHMCS (profile data) + +**Combined**: +- `User` - Complete entity (auth + profile) + +**Usage**: +- JWT validation → `UserAuth` +- API responses → `User` +- Internal mapping → `CustomerData` (via providers) + diff --git a/CUSTOMER-DOMAIN-REFACTOR.md b/CUSTOMER-DOMAIN-REFACTOR.md new file mode 100644 index 00000000..08959585 --- /dev/null +++ b/CUSTOMER-DOMAIN-REFACTOR.md @@ -0,0 +1,541 @@ +# Customer Domain Refactor Plan +## Moving User Entity Types from Auth to Customer Domain + +## Overview + +**Problem**: Auth domain currently contains both authentication mechanisms AND user entity types, which mixes concerns. + +**Solution**: +- Move all user entity types (`PortalUser`, `UserProfile`) from `auth/` to `customer/` domain +- Add Portal (Prisma) as a provider alongside WHMCS +- Keep auth domain focused ONLY on authentication mechanisms +- Consolidate schemas and contracts into single files where appropriate + +## Architecture Vision + +``` +packages/domain/customer/ +├── index.ts # Main exports +├── types.ts # ALL types in one file (schemas + inferred types) +│ ├── Schemas (Zod) +│ │ ├── customerAddressSchema +│ │ ├── portalUserSchema (NEW - from auth) +│ │ ├── customerProfileSchema +│ │ └── userProfileSchema (NEW - from auth) +│ │ +│ └── Types (inferred from schemas) +│ ├── CustomerAddress +│ ├── PortalUser (NEW - auth state from portal DB) +│ ├── CustomerProfile (WHMCS profile data) +│ └── UserProfile (NEW - PortalUser + CustomerProfile) +│ +├── providers/ +│ ├── index.ts +│ │ +│ ├── portal/ (NEW - Portal DB provider) +│ │ ├── index.ts +│ │ ├── types.ts # PrismaUserRaw +│ │ └── mapper.ts # Prisma → PortalUser +│ │ +│ ├── whmcs/ (EXISTING) +│ │ ├── index.ts +│ │ ├── types.ts # WHMCS raw types +│ │ └── mapper.ts # WHMCS → CustomerProfile +│ │ +│ └── salesforce/ (EXISTING if needed) +│ + +packages/domain/auth/ +├── index.ts +├── types.ts # Authentication mechanism types ONLY +│ ├── Schemas (Zod) +│ │ ├── loginRequestSchema +│ │ ├── signupRequestSchema +│ │ ├── passwordResetSchema +│ │ ├── authTokensSchema +│ │ ├── authResponseSchema (user: UserProfile reference, tokens) +│ │ └── mfaSchemas +│ │ +│ └── Types (inferred) +│ ├── LoginRequest +│ ├── SignupRequest +│ ├── AuthTokens +│ └── AuthResponse +│ +└── NO user entity types, NO PortalUser, NO UserProfile +``` + +## Detailed Implementation Plan + +### Phase 1: Create New Customer Domain Structure + +#### 1.1 Create consolidated types file +**File:** `packages/domain/customer/types.ts` + +Move from multiple files into one: +- From `customer/schema.ts`: All existing customer schemas +- From `customer/contract.ts`: Interface definitions (convert to schema-first) +- From `auth/schema.ts`: `portalUserSchema`, `userProfileSchema` + +Structure: +```typescript +import { z } from "zod"; + +// ============================================================================ +// Common Schemas +// ============================================================================ +export const customerAddressSchema = z.object({...}); +export const customerEmailPreferencesSchema = z.object({...}); +// ... other existing schemas + +// ============================================================================ +// Portal User Schema (Auth State from Portal DB) +// ============================================================================ +/** + * PortalUser represents ONLY auth state stored in portal database + * Provider: Prisma (portal DB) + */ +export const portalUserSchema = 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(), +}); + +// ============================================================================ +// Customer Profile Schema (WHMCS Profile Data) +// ============================================================================ +/** + * CustomerProfile represents profile data from WHMCS + * Provider: WHMCS + */ +export const customerProfileSchema = z.object({ + id: z.string(), + email: z.string(), + 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(), + address: customerAddressSchema.optional(), + language: z.string().nullable().optional(), + currencyCode: z.string().nullable().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), +}); + +// ============================================================================ +// User Profile Schema (Complete User = PortalUser + CustomerProfile) +// ============================================================================ +/** + * UserProfile is the complete authenticated user + * Composition: PortalUser (auth state) + CustomerProfile (WHMCS data) + */ +export const userProfileSchema = portalUserSchema.extend({ + // Add CustomerProfile fields + 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(), + address: customerAddressSchema.optional(), + language: z.string().nullable().optional(), + currencyCode: z.string().nullable().optional(), +}); + +// ============================================================================ +// Inferred Types (Schema-First) +// ============================================================================ +export type CustomerAddress = z.infer; +export type PortalUser = z.infer; +export type CustomerProfile = z.infer; +export type UserProfile = z.infer; +// ... all other types +``` + +#### 1.2 Create Portal Provider +**Directory:** `packages/domain/customer/providers/portal/` + +**File:** `packages/domain/customer/providers/portal/types.ts` +```typescript +import type { UserRole } from "../../types"; + +/** + * Raw Prisma user data + * This interface matches Prisma schema but doesn't depend on @prisma/client + */ +export interface PrismaUserRaw { + id: string; + email: string; + passwordHash: string | null; + role: UserRole; + mfaSecret: string | null; + emailVerified: boolean; + failedLoginAttempts: number; + lockedUntil: Date | null; + lastLoginAt: Date | null; + createdAt: Date; + updatedAt: Date; +} +``` + +**File:** `packages/domain/customer/providers/portal/mapper.ts` +```typescript +import type { PrismaUserRaw } from "./types"; +import type { PortalUser } from "../../types"; + +/** + * Maps raw Prisma user data to PortalUser domain type + */ +export function mapPrismaUserToPortalUser(raw: PrismaUserRaw): PortalUser { + return { + id: raw.id, + email: raw.email, + role: raw.role, + mfaEnabled: !!raw.mfaSecret, + emailVerified: raw.emailVerified, + lastLoginAt: raw.lastLoginAt?.toISOString(), + createdAt: raw.createdAt.toISOString(), + updatedAt: raw.updatedAt.toISOString(), + }; +} +``` + +**File:** `packages/domain/customer/providers/portal/index.ts` +```typescript +export * from "./mapper"; +export * from "./types"; +``` + + +#### 1.3 Update Customer Domain Index +**File:** `packages/domain/customer/index.ts` +```typescript +/** + * Customer Domain + * + * Contains all user/customer entity types and their providers: + * - PortalUser: Auth state from portal DB (via Prisma) + * - CustomerProfile: Profile data from WHMCS + * - UserProfile: Complete user (PortalUser + CustomerProfile) + */ + +// Export all types +export type { + // Core types + PortalUser, + CustomerProfile, + UserProfile, + CustomerAddress, + // ... all other types +} from "./types"; + +// Export schemas for validation +export { + portalUserSchema, + customerProfileSchema, + userProfileSchema, + customerAddressSchema, + // ... all other schemas +} from "./types"; + +// Export providers +export * as Providers from "./providers"; +``` + +#### 1.4 Update Providers Index +**File:** `packages/domain/customer/providers/index.ts` +```typescript +import * as PortalModule from "./portal"; +import * as WhmcsModule from "./whmcs"; + +export const Portal = PortalModule; +export const Whmcs = WhmcsModule; + +export { PortalModule, WhmcsModule }; +``` + +### Phase 2: Clean Up Auth Domain + +#### 2.1 Remove user entity types from auth +**File:** `packages/domain/auth/types.ts` (create new, consolidate schema.ts + contract.ts) + +Remove: +- `portalUserSchema` → moved to customer domain +- `userProfileSchema` → moved to customer domain +- `PortalUser` type → moved to customer domain +- `UserProfile` / `AuthenticatedUser` type → moved to customer domain + +Keep ONLY: +- Authentication mechanism schemas (login, signup, password reset, MFA) +- Token schemas (authTokensSchema, refreshTokenSchema) +- Auth response schemas (authResponseSchema references UserProfile from customer domain) + +Structure: +```typescript +import { z } from "zod"; +import { userProfileSchema } from "../customer/types"; + +// ============================================================================ +// Request Schemas (Authentication Mechanisms) +// ============================================================================ +export const loginRequestSchema = z.object({...}); +export const signupRequestSchema = z.object({...}); +export const passwordResetRequestSchema = z.object({...}); +// ... all auth request schemas + +// ============================================================================ +// Token Schemas +// ============================================================================ +export const authTokensSchema = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.string(), + refreshExpiresAt: z.string(), + tokenType: z.literal("Bearer"), +}); + +// ============================================================================ +// Response Schemas (Reference UserProfile from Customer Domain) +// ============================================================================ +export const authResponseSchema = z.object({ + user: userProfileSchema, // from customer domain + tokens: authTokensSchema, +}); + +export const signupResultSchema = z.object({ + user: userProfileSchema, + tokens: authTokensSchema, +}); + +// ============================================================================ +// Inferred Types +// ============================================================================ +export type LoginRequest = z.infer; +export type AuthTokens = z.infer; +export type AuthResponse = z.infer; +export type SignupResult = z.infer; +// ... all other auth types +``` + +#### 2.2 Delete auth/schema.ts and auth/contract.ts +After consolidating into `auth/types.ts`, remove: +- `packages/domain/auth/schema.ts` +- `packages/domain/auth/contract.ts` + +#### 2.3 Delete auth/providers/ +Remove `packages/domain/auth/providers/` entirely (Portal provider moved to customer domain) + +#### 2.4 Update Auth Domain Index +**File:** `packages/domain/auth/index.ts` +```typescript +/** + * Auth Domain + * + * Contains ONLY authentication mechanisms: + * - Login, Signup, Password Management + * - Token Management (JWT) + * - MFA, SSO + * + * User entity types are in customer domain. + */ + +export type { + LoginRequest, + SignupRequest, + AuthTokens, + AuthResponse, + SignupResult, + PasswordChangeResult, + // ... all auth mechanism types +} from "./types"; + +export { + loginRequestSchema, + signupRequestSchema, + authTokensSchema, + authResponseSchema, + // ... all auth schemas +} from "./types"; +``` + +### Phase 3: Update BFF Layer + +#### 3.1 Update BFF User Mapper +**File:** `apps/bff/src/infra/mappers/user.mapper.ts` + +```typescript +import type { User as PrismaUser } from "@prisma/client"; +import { Providers as CustomerProviders } from "@customer-portal/domain/customer"; +import type { PortalUser } from "@customer-portal/domain/customer"; + +/** + * Adapter: Converts Prisma User to domain PortalUser + */ +export function mapPrismaUserToDomain(user: PrismaUser): PortalUser { + const prismaUserRaw: CustomerProviders.Portal.PrismaUserRaw = { + id: user.id, + email: user.email, + passwordHash: user.passwordHash, + role: user.role, + mfaSecret: user.mfaSecret, + emailVerified: user.emailVerified, + failedLoginAttempts: user.failedLoginAttempts, + lockedUntil: user.lockedUntil, + lastLoginAt: user.lastLoginAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + + return CustomerProviders.Portal.mapPrismaUserToPortalUser(prismaUserRaw); +} +``` + +#### 3.2 Update Auth Workflow Services +Update imports in: +- `apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts` +- `apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts` +- `apps/bff/src/modules/auth/application/auth.facade.ts` + +Change: +```typescript +// OLD +import type { UserProfile, SignupResult } from "@customer-portal/domain/auth"; + +// NEW +import type { UserProfile } from "@customer-portal/domain/customer"; +import type { SignupResult } from "@customer-portal/domain/auth"; +``` + +#### 3.3 Update Users Service +**File:** `apps/bff/src/modules/users/users.service.ts` + +Construct UserProfile directly (already doing this!): +```typescript +import { + type PortalUser, + type CustomerProfile, + type UserProfile, +} from "@customer-portal/domain/customer"; + +async getProfile(userId: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId); + + // Construct UserProfile directly + const profile: UserProfile = { + // Auth state from portal + id: user.id, + email: client.email, + role: user.role, + emailVerified: user.emailVerified, + mfaEnabled: user.mfaSecret !== null, + lastLoginAt: user.lastLoginAt?.toISOString(), + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + + // Profile from WHMCS + firstname: client.firstname || null, + lastname: client.lastname || null, + fullname: client.fullname || null, + companyname: client.companyName || null, + phonenumber: client.phoneNumber || null, + address: client.address || undefined, + language: client.language || null, + currencyCode: client.currencyCode || null, + }; + + return profile; +} +``` + +#### 3.4 Update JWT Strategy +**File:** `apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts` + +```typescript +import type { PortalUser } from "@customer-portal/domain/customer"; + +async validate(payload: JwtPayload): Promise { + const user = await this.prisma.user.findUnique({...}); + return this.userMapper.mapPrismaUserToDomain(user); // Returns PortalUser +} +``` + +### Phase 4: Delete Old Files + +#### 4.1 Delete from customer domain +- `packages/domain/customer/schema.ts` (consolidated into types.ts) +- `packages/domain/customer/contract.ts` (consolidated into types.ts) + +#### 4.2 Delete from auth domain +- `packages/domain/auth/schema.ts` (consolidated into types.ts) +- `packages/domain/auth/contract.ts` (consolidated into types.ts) +- `packages/domain/auth/providers/` (moved to customer domain) + +#### 4.3 Delete from BFF (already done in previous cleanup) +- Redundant type files already removed + +### Phase 5: Verification + +#### 5.1 TypeScript Compilation +```bash +cd packages/domain && npx tsc --noEmit +cd apps/bff && npx tsc --noEmit +``` + +#### 5.2 Import Verification +Verify: +- ✅ No imports of `PortalUser` or `UserProfile` from `@customer-portal/domain/auth` +- ✅ All user entity imports come from `@customer-portal/domain/customer` +- ✅ Auth domain only exports authentication mechanism types +- ✅ BFF constructs UserProfile directly via object creation + +#### 5.3 Provider Pattern Consistency +Verify all domains follow provider pattern: +- ✅ `customer/providers/portal/` - Prisma → PortalUser +- ✅ `customer/providers/whmcs/` - WHMCS → CustomerProfile +- ✅ Other domains (billing, subscriptions) have consistent provider structure + +## Summary of Changes + +### What's Moving +- `PortalUser` type: `auth/` → `customer/` +- `UserProfile` type: `auth/` → `customer/` +- Portal (Prisma) provider: `auth/providers/prisma/` → `customer/providers/portal/` +- All schemas: `*/schema.ts` + `*/contract.ts` → `*/types.ts` (consolidated) + +### What's Staying +- Auth domain: Only authentication mechanisms (login, signup, tokens, password, MFA) +- Customer domain: All user/customer entity types + providers + +### New Components +- `customer/types.ts` - Single file with all schemas and types (including PortalUser, UserProfile) +- `customer/providers/portal/` - Portal DB provider for PortalUser +- `auth/types.ts` - Single file with all auth mechanism types (no user entities) + +## Benefits + +1. **Clear Separation of Concerns** + - Auth = "How do I authenticate?" (mechanisms) + - Customer = "Who am I?" (entities) + +2. **Provider Pattern Consistency** + - Portal provider (Prisma) alongside WHMCS provider + - Same entity, different data sources + +3. **Simplified File Structure** + - One `types.ts` instead of `schema.ts` + `contract.ts` + - Less file navigation + +4. **Simple & Direct** + - No unnecessary abstraction layers + - Direct object construction with type safety + +5. **Standard DDD** + - Customer is the aggregate root + - Auth is a mechanism that references customer + diff --git a/INVOICE-VALIDATION-CLEANUP-PLAN.md b/INVOICE-VALIDATION-CLEANUP-PLAN.md new file mode 100644 index 00000000..1eaedaac --- /dev/null +++ b/INVOICE-VALIDATION-CLEANUP-PLAN.md @@ -0,0 +1,167 @@ +# Invoice Validation Cleanup Plan + +## Problem Statement + +The `InvoiceValidatorService` contains redundant validation logic that duplicates what Zod schemas already provide. + +## Current Redundant Code + +### ❌ In BFF: `apps/bff/src/modules/invoices/validators/invoice-validator.service.ts` + +```typescript +// ALL OF THESE ARE REDUNDANT: +validateInvoiceId(invoiceId: number): void { + if (!invoiceId || invoiceId < 1) throw new BadRequestException("Invalid invoice ID"); +} + +validateUserId(userId: string): void { + if (!userId || typeof userId !== "string" || userId.trim().length === 0) { + throw new BadRequestException("Invalid user ID"); + } +} + +validatePagination(options: Partial): void { + if (page < 1) throw new BadRequestException("Page must be greater than 0"); + if (limit < min || limit > max) throw new BadRequestException(`Limit must be between...`); +} + +validateInvoiceStatus(status: string): InvoiceStatus { + if (!isValidInvoiceStatus(status)) throw new BadRequestException(`Invalid status...`); + return status as InvoiceStatus; +} + +validateWhmcsClientId(clientId: number | undefined): void { + if (!clientId || clientId < 1) throw new BadRequestException("Invalid WHMCS client ID"); +} + +validatePaymentGateway(gatewayName: string): void { + if (!gatewayName || typeof gatewayName !== "string" || gatewayName.trim().length === 0) { + throw new BadRequestException("Invalid payment gateway name"); + } +} + +validateGetInvoicesOptions(options: InvoiceListQuery): InvoiceValidationResult { + // Calls the above functions - all redundant! +} +``` + +### ✅ Already Exists: `packages/domain/billing/schema.ts` + +```typescript +// THESE ALREADY HANDLE ALL VALIDATION: +export const invoiceSchema = z.object({ + id: z.number().int().positive("Invoice id must be positive"), + // ... +}); + +export const invoiceListQuerySchema = z.object({ + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().max(100).optional(), + status: invoiceListStatusSchema.optional(), +}); +``` + +## Solution: Use Schemas Directly + +### Step 1: Add Missing Schemas to Domain + +Only ONE new schema needed: + +```typescript +// packages/domain/common/validation.ts (add to existing file) +export const urlSchema = z.string().url(); + +export function validateUrl(url: string): { isValid: boolean; errors: string[] } { + const result = urlSchema.safeParse(url); + return { + isValid: result.success, + errors: result.success ? [] : result.error.issues.map(i => i.message), + }; +} +``` + +### Step 2: Use Schemas at Controller/Entry Point + +```typescript +// apps/bff/src/modules/invoices/invoices.controller.ts +import { invoiceListQuerySchema } from "@customer-portal/domain/billing"; +import { ZodValidationPipe } from "@bff/core/validation"; + +@Controller("invoices") +export class InvoicesController { + @Get() + async getInvoices( + @Query(new ZodValidationPipe(invoiceListQuerySchema)) query: InvoiceListQuery, + @Request() req: RequestWithUser + ) { + // query is already validated by Zod pipe! + // No need for validator service + return this.invoicesService.getInvoices(req.user.id, query); + } +} +``` + +### Step 3: Delete InvoiceValidatorService + +The entire service can be removed: +- ❌ Delete `apps/bff/src/modules/invoices/validators/invoice-validator.service.ts` +- ❌ Delete `apps/bff/src/modules/invoices/types/invoice-service.types.ts` (InvoiceValidationResult) +- ✅ Use Zod schemas + `ZodValidationPipe` instead + +## Migration Steps + +### A. Add URL Validation to Domain (Only Useful One) + +1. Add `urlSchema` and `validateUrl()` to `packages/domain/common/validation.ts` +2. Export from `packages/domain/common/index.ts` + +### B. Update BFF to Use Schemas Directly + +3. Update `InvoiceRetrievalService` to use schemas instead of validator +4. Update `InvoicesOrchestratorService` to use schemas +5. Update controller to use `ZodValidationPipe` with domain schemas + +### C. Remove Redundant Code + +6. Delete `InvoiceValidatorService` +7. Delete `InvoiceValidationResult` type +8. Remove from `InvoicesModule` providers + +## Benefits + +✅ Single source of truth (Zod schemas in domain) +✅ No duplicate validation logic +✅ Type-safe (schemas generate types) +✅ Consistent error messages +✅ Less code to maintain + +## General Pattern + +This applies to ALL modules: + +```typescript +// ❌ DON'T: Create validator service for field validation +class XyzValidatorService { + validateField(value) { ... } // Redundant with schema +} + +// ✅ DO: Use Zod schema + validation pipe +const xyzRequestSchema = z.object({ + field: z.string().min(1), +}); + +@Post() +async create(@Body(new ZodValidationPipe(xyzRequestSchema)) body: XyzRequest) { + // Already validated! +} +``` + +**Validator Services should ONLY exist for:** +- Business logic validation (requires multiple fields or data) +- Infrastructure validation (requires DB/API calls) + +**NOT for:** +- Field format validation (use Zod schemas) +- Type validation (use Zod schemas) +- Range validation (use Zod schemas) + diff --git a/VALIDATION-CLEANUP-COMPLETE.md b/VALIDATION-CLEANUP-COMPLETE.md new file mode 100644 index 00000000..ede7b564 --- /dev/null +++ b/VALIDATION-CLEANUP-COMPLETE.md @@ -0,0 +1,157 @@ +# Validation Cleanup Complete ✅ + +## Summary + +Successfully removed redundant validation layer and established proper validation architecture using Zod schemas. + +## Changes Made + +### 1. Added URL Validation to Domain ✅ + +**File**: `packages/domain/common/validation.ts` + +Added URL validation utilities: +- `urlSchema` - Zod schema for URL validation +- `validateUrlOrThrow()` - Throwing variant +- `validateUrl()` - Non-throwing variant returning validation result +- `isValidUrl()` - Boolean check + +### 2. Refactored Invoice Services ✅ + +**Files Changed**: +- `apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts` +- `apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts` +- `apps/bff/src/modules/invoices/invoices.controller.ts` + +**Changes**: +- Removed dependency on `InvoiceValidatorService` +- Use Zod schemas directly for validation +- Controller uses `ZodValidationPipe` with `invoiceListQuerySchema` +- Services validate using domain schemas: `invoiceSchema`, `invoiceListQuerySchema` +- Removed redundant manual validation checks + +### 3. Deleted Redundant Files ✅ + +**Deleted**: +- ❌ `apps/bff/src/modules/invoices/validators/invoice-validator.service.ts` +- ❌ `apps/bff/src/modules/invoices/types/invoice-service.types.ts` + +**Created**: +- ✅ `apps/bff/src/modules/invoices/types/invoice-monitoring.types.ts` (for infrastructure types) + +**Updated**: +- `apps/bff/src/modules/invoices/invoices.module.ts` - Removed validator from providers +- `apps/bff/src/modules/invoices/index.ts` - Updated exports + +### 4. Preserved Infrastructure Types ✅ + +Created `invoice-monitoring.types.ts` for BFF-specific infrastructure concerns: +- `InvoiceServiceStats` - Monitoring/metrics +- `InvoiceHealthStatus` - Health check results + +## Validation Architecture (Confirmed) + +### ✅ Schema Validation (Domain - Zod) +**Location**: `packages/domain/*/schema.ts` +**Purpose**: Format, type, range validation +**Examples**: +```typescript +export const invoiceListQuerySchema = z.object({ + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().max(100).optional(), + status: invoiceListStatusSchema.optional(), +}); +``` + +### ✅ Business Validation (Domain - Pure Functions) +**Location**: `packages/domain/*/validation.ts` +**Purpose**: Cross-field rules, business constraints +**Examples**: +```typescript +// packages/domain/mappings/validation.ts +export function validateNoConflicts( + request: CreateMappingRequest, + existingMappings: UserIdMapping[] +): MappingValidationResult { + // Business rule: no duplicate userId or whmcsClientId +} +``` + +### ✅ Infrastructure Validation (BFF - Services) +**Location**: `apps/bff/src/modules/*/services/*.service.ts` +**Purpose**: Data-dependent validation (DB/API calls) +**Examples**: +```typescript +// Invoice retrieval service +private async getUserMapping(userId: string): Promise { + validateUuidV4OrThrow(userId); // Domain validation + const mapping = await this.mappingsService.findByUserId(userId); // DB call + if (!mapping?.whmcsClientId) { + throw new NotFoundException("WHMCS client mapping not found"); + } + return mapping; +} +``` + +## Key Principle Established + +### ❌ DON'T: Create validator services for field validation +```typescript +class XyzValidatorService { + validateField(value) { + if (!value || value < 1) throw new Error(...); // Redundant with schema + } +} +``` + +### ✅ DO: Use Zod schemas + validation pipe +```typescript +const xyzRequestSchema = z.object({ + field: z.number().int().positive(), // Schema handles validation +}); + +@Post() +async create( + @Body(new ZodValidationPipe(xyzRequestSchema)) body: XyzRequest +) { + // Already validated! +} +``` + +## Validation Coverage by Module + +| Module | Schema Validation | Business Validation | Infrastructure | Status | +|--------|------------------|-------------------|----------------|--------| +| Mappings | ✅ `domain/mappings/schema.ts` | ✅ `domain/mappings/validation.ts` | ✅ BFF service | ✅ Complete | +| Invoices | ✅ `domain/billing/schema.ts` | N/A (no business rules) | ✅ BFF service | ✅ Complete | +| Orders | ✅ `domain/orders/schema.ts` | ✅ `domain/orders/validation.ts` | ✅ BFF service | ✅ Complete | +| SIM | ✅ `domain/sim/schema.ts` | ✅ `domain/sim/validation.ts` | ✅ BFF service | ✅ Complete | +| Common | ✅ `domain/common/validation.ts` | N/A | N/A | ✅ Complete | + +## Verification + +✅ Domain package compiles without errors +✅ BFF compiles without errors +✅ No linter errors +✅ All validation handled by schemas at entry points +✅ Infrastructure concerns properly separated + +## Benefits Achieved + +1. **Single Source of Truth**: Zod schemas in domain define all field validation +2. **No Duplication**: Removed redundant validation logic +3. **Type Safety**: Schemas generate TypeScript types +4. **Consistency**: Same validation rules everywhere +5. **Maintainability**: Less code, clearer responsibilities +6. **Proper Separation**: Schema → Business → Infrastructure layers clearly defined + +## Pattern for Future Development + +When adding new validation: + +1. **Field validation?** → Add to domain schema (Zod) +2. **Business rule?** → Add pure function to `domain/*/validation.ts` +3. **Needs DB/API?** → Keep in BFF service layer + +**Never create a validator service just to duplicate what schemas already do!** + diff --git a/apps/bff/src/core/utils/validation.util.ts b/apps/bff/src/core/utils/validation.util.ts deleted file mode 100644 index 4bf31ba5..00000000 --- a/apps/bff/src/core/utils/validation.util.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from "zod"; -import { BadRequestException } from "@nestjs/common"; - -// Simple Zod schemas for common validations -export const emailSchema = z - .string() - .email() - .transform(email => email.toLowerCase().trim()); -export const uuidSchema = z.string().uuid(); - -export function normalizeAndValidateEmail(email: string): string { - try { - return emailSchema.parse(email); - } catch { - throw new BadRequestException("Invalid email format"); - } -} - -export function validateUuidV4OrThrow(id: string): string { - try { - return uuidSchema.parse(id); - } catch { - throw new Error("Invalid user ID format"); - } -} diff --git a/apps/bff/src/infra/mappers/user.mapper.ts b/apps/bff/src/infra/mappers/user.mapper.ts index b7bbb3e4..9450167a 100644 --- a/apps/bff/src/infra/mappers/user.mapper.ts +++ b/apps/bff/src/infra/mappers/user.mapper.ts @@ -1,38 +1,45 @@ /** * User DB Mapper * - * Maps Prisma User entity to Domain AuthenticatedUser type + * Adapts @prisma/client User to domain UserAuth type * - * NOTE: This is an infrastructure concern - Prisma is BFF's ORM implementation detail. - * Domain layer should not know about Prisma types. + * NOTE: This is an infrastructure adapter - Prisma is BFF's ORM implementation detail. + * The domain provider handles the actual mapping logic. */ import type { User as PrismaUser } from "@prisma/client"; -import type { AuthenticatedUser } from "@customer-portal/domain/auth"; +import type { UserAuth } from "@customer-portal/domain/customer"; +import { + mapPrismaUserToUserAuth, + type PrismaUserRaw +} from "@customer-portal/domain/customer/providers/portal"; /** - * Maps Prisma User entity to Domain AuthenticatedUser type - * NOTE: Profile fields must be fetched from WHMCS - this only maps auth state + * Maps Prisma User entity to Domain UserAuth type + * + * This adapter converts the @prisma/client User to the domain's PrismaUserRaw type, + * then uses the domain portal provider mapper to get UserAuth. + * + * NOTE: UserAuth contains ONLY auth state. Profile data comes from WHMCS. + * For complete user profile, use UsersService.getProfile() which fetches from WHMCS. */ -export function mapPrismaUserToDomain(user: PrismaUser): AuthenticatedUser { - return { +export function mapPrismaUserToDomain(user: PrismaUser): UserAuth { + // Convert @prisma/client User to domain PrismaUserRaw + const prismaUserRaw: PrismaUserRaw = { id: user.id, email: user.email, + passwordHash: user.passwordHash, role: user.role, - mfaEnabled: !!user.mfaSecret, + mfaSecret: user.mfaSecret, emailVerified: user.emailVerified, - lastLoginAt: user.lastLoginAt?.toISOString(), - // Profile fields null - fetched from WHMCS - firstname: null, - lastname: null, - fullname: null, - companyname: null, - phonenumber: null, - language: null, - currencyCode: null, - address: undefined, - createdAt: user.createdAt.toISOString(), - updatedAt: user.updatedAt.toISOString(), + failedLoginAttempts: user.failedLoginAttempts, + lockedUntil: user.lockedUntil, + lastLoginAt: user.lastLoginAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, }; + + // Use domain provider mapper + return mapPrismaUserToUserAuth(prismaUserRaw); } diff --git a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts index 140dfdfd..f52a6d23 100644 --- a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts +++ b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts @@ -5,7 +5,7 @@ import { CacheService } from "@bff/infra/cache/cache.service"; import { Invoice, InvoiceList } from "@customer-portal/domain/billing"; import { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions"; import { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments"; -import { Customer } from "@customer-portal/domain/customer"; +import { Providers as CustomerProviders } from "@customer-portal/domain/customer"; export interface CacheOptions { ttl?: number; @@ -146,16 +146,17 @@ export class WhmcsCacheService { /** * Get cached client data + * Returns WhmcsClient (type inferred from domain) */ - async getClientData(clientId: number): Promise { + async getClientData(clientId: number) { const key = this.buildClientKey(clientId); - return this.get(key, "client"); + return this.get(key, "client"); } /** * Cache client data */ - async setClientData(clientId: number, data: Customer): Promise { + async setClientData(clientId: number, data: ReturnType) { const key = this.buildClientKey(clientId); await this.set(key, data, "client", [`client:${clientId}`]); } diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts index 8a891b66..b8b7676c 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts @@ -1,16 +1,18 @@ import { Injectable } from "@nestjs/common"; import type { WhmcsClientResponse, - WhmcsGetInvoicesParams, - WhmcsGetClientsProductsParams, WhmcsCreateSsoTokenParams, WhmcsValidateLoginParams, WhmcsAddClientParams, - WhmcsGetPayMethodsParams, +} from "@customer-portal/domain/customer"; +import type { + WhmcsGetInvoicesParams, WhmcsCreateInvoiceParams, WhmcsUpdateInvoiceParams, WhmcsCapturePaymentParams, -} from "../../types/whmcs-api.types"; +} from "@customer-portal/domain/billing"; +import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments"; +import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions"; import type { WhmcsInvoiceListResponse, WhmcsInvoiceResponse, diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts index 60506c00..22398b8a 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts @@ -9,14 +9,16 @@ import { WhmcsRequestQueueService } from "@bff/core/queue/services/whmcs-request import type { WhmcsAddClientParams, WhmcsValidateLoginParams, - WhmcsGetInvoicesParams, - WhmcsGetClientsProductsParams, - WhmcsGetPayMethodsParams, WhmcsCreateSsoTokenParams, +} from "@customer-portal/domain/customer"; +import type { + WhmcsGetInvoicesParams, WhmcsCreateInvoiceParams, WhmcsUpdateInvoiceParams, WhmcsCapturePaymentParams, -} from "../../types/whmcs-api.types"; +} from "@customer-portal/domain/billing"; +import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments"; +import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions"; import type { WhmcsErrorResponse } from "@customer-portal/domain/common"; import type { WhmcsRequestOptions, WhmcsConnectionStats } from "../types/connection.types"; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts index 7672070c..ebbf5d04 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts @@ -3,19 +3,18 @@ import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; -import { +import type { WhmcsValidateLoginParams, WhmcsAddClientParams, WhmcsClientResponse, -} from "../types/whmcs-api.types"; +} from "@customer-portal/domain/customer"; import type { WhmcsAddClientResponse, WhmcsValidateLoginResponse, } from "@customer-portal/domain/customer"; import { Providers as CustomerProviders, - type Customer, - type CustomerAddress, + type Address, } from "@customer-portal/domain/customer"; @Injectable() @@ -56,8 +55,9 @@ export class WhmcsClientService { /** * Get client details by ID + * Returns WhmcsClient (type inferred from domain mapper) */ - async getClientDetails(clientId: number): Promise { + async getClientDetails(clientId: number) { try { // Try cache first const cached = await this.cacheService.getClientData(clientId); @@ -92,8 +92,9 @@ export class WhmcsClientService { /** * Get client details by email + * Returns WhmcsClient (type inferred from domain mapper) */ - async getClientDetailsByEmail(email: string): Promise { + async getClientDetailsByEmail(email: string) { try { const response = await this.connectionService.getClientDetailsByEmail(email); diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 67607648..99b430e8 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -5,12 +5,12 @@ import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema, Providers } fro import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { WhmcsCurrencyService } from "./whmcs-currency.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; -import { +import type { WhmcsGetInvoicesParams, WhmcsCreateInvoiceParams, WhmcsUpdateInvoiceParams, WhmcsCapturePaymentParams, -} from "../types/whmcs-api.types"; +} from "@customer-portal/domain/billing"; import type { WhmcsInvoiceListResponse, WhmcsInvoiceResponse, diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts index be38cece..9ecd7a98 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts @@ -10,10 +10,8 @@ import { } from "@customer-portal/domain/payments"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; -import type { - WhmcsCreateSsoTokenParams, - WhmcsGetPayMethodsParams, -} from "../types/whmcs-api.types"; +import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer"; +import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments"; import type { WhmcsPaymentMethod, WhmcsPaymentMethodListResponse, diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts index 409e99c6..8d2bfaa2 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts @@ -2,7 +2,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { Injectable, Inject } from "@nestjs/common"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; -import { WhmcsCreateSsoTokenParams } from "../types/whmcs-api.types"; +import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer"; import type { WhmcsSsoResponse } from "@customer-portal/domain/customer"; @Injectable() diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts index f54b2099..d0e1dc84 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts @@ -5,7 +5,7 @@ import { Subscription, SubscriptionList, Providers } from "@customer-portal/doma import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { WhmcsCurrencyService } from "./whmcs-currency.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; -import { WhmcsGetClientsProductsParams } from "../types/whmcs-api.types"; +import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions"; import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions"; export interface SubscriptionFilters { diff --git a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts deleted file mode 100644 index c8014a2d..00000000 --- a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * WHMCS API Request Parameter Types - * - * These are BFF-specific request parameter types for WHMCS API calls. - * Response types have been moved to domain packages. - */ - -import { Providers as CustomerProviders } from "@customer-portal/domain/customer"; - -// Re-export types from domain for convenience (used by transformers/mappers) -export type WhmcsClient = CustomerProviders.WhmcsRaw.WhmcsClient; -export type WhmcsClientResponse = CustomerProviders.WhmcsRaw.WhmcsClientResponse; - -import { Providers as SubscriptionProviders } from "@customer-portal/domain/subscriptions"; -export type WhmcsProduct = SubscriptionProviders.WhmcsRaw.WhmcsProductRaw; - -// Request Parameters -export interface WhmcsGetInvoicesParams { - userid?: number; // WHMCS API uses 'userid' not 'clientid' - status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; - limitstart?: number; - limitnum?: number; - orderby?: "id" | "invoicenum" | "date" | "duedate" | "total" | "status"; - order?: "ASC" | "DESC"; - [key: string]: unknown; -} - -export interface WhmcsGetClientsProductsParams { - clientid: number; - serviceid?: number; - pid?: number; - domain?: string; - limitstart?: number; - limitnum?: number; - orderby?: "id" | "productname" | "regdate" | "nextduedate"; - order?: "ASC" | "DESC"; - [key: string]: unknown; -} - -export interface WhmcsCreateSsoTokenParams { - client_id: number; - destination?: string; - sso_redirect_path?: string; - [key: string]: unknown; -} - -export interface WhmcsValidateLoginParams { - email: string; - password2: string; - [key: string]: unknown; -} - -export interface WhmcsAddClientParams { - firstname: string; - lastname: string; - email: string; - address1?: string; - city?: string; - state?: string; - postcode?: string; - country?: string; - phonenumber?: string; - password2: string; - companyname?: string; - currency?: string; - groupid?: number; - customfields?: Record; - language?: string; - clientip?: string; - notes?: string; - marketing_emails_opt_in?: boolean; - no_email?: boolean; - [key: string]: unknown; -} - -export interface WhmcsGetPayMethodsParams extends Record { - clientid: number; - paymethodid?: number; - type?: "BankAccount" | "CreditCard"; -} - -export interface WhmcsCreateInvoiceParams { - userid: number; - status?: - | "Draft" - | "Paid" - | "Unpaid" - | "Cancelled" - | "Refunded" - | "Collections" - | "Overdue" - | "Payment Pending"; - sendnotification?: boolean; - paymentmethod?: string; - taxrate?: number; - taxrate2?: number; - date?: string; // YYYY-MM-DD format - duedate?: string; // YYYY-MM-DD format - notes?: string; - itemdescription1?: string; - itemamount1?: number; - itemtaxed1?: boolean; - itemdescription2?: string; - itemamount2?: number; - itemtaxed2?: boolean; - // Can have up to 24 line items (itemdescription1-24, itemamount1-24, itemtaxed1-24) - [key: string]: unknown; -} - -export interface WhmcsUpdateInvoiceParams { - invoiceid: number; - status?: "Draft" | "Paid" | "Unpaid" | "Cancelled" | "Refunded" | "Collections" | "Overdue"; - duedate?: string; // YYYY-MM-DD format - notes?: string; - [key: string]: unknown; -} - -export interface WhmcsCapturePaymentParams { - invoiceid: number; - cvv?: string; - cardnum?: string; - cccvv?: string; - cardtype?: string; - cardexp?: string; - // For existing payment methods - paymentmethodid?: number; - // Manual payment capture - transid?: string; - gateway?: string; - [key: string]: unknown; -} - diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts index 00dae25f..5e112d93 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.service.ts @@ -3,7 +3,7 @@ import { Injectable, Inject } from "@nestjs/common"; import type { Invoice, InvoiceList } from "@customer-portal/domain/billing"; import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions"; import type { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments"; -import { Providers as CustomerProviders, type Customer, type CustomerAddress } from "@customer-portal/domain/customer"; +import { Providers as CustomerProviders, type Address } from "@customer-portal/domain/customer"; import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service"; import { WhmcsInvoiceService, InvoiceFilters } from "./services/whmcs-invoice.service"; import { @@ -14,11 +14,11 @@ import { WhmcsClientService } from "./services/whmcs-client.service"; import { WhmcsPaymentService } from "./services/whmcs-payment.service"; import { WhmcsSsoService } from "./services/whmcs-sso.service"; import { WhmcsOrderService } from "./services/whmcs-order.service"; -import { +import type { WhmcsAddClientParams, WhmcsClientResponse, - WhmcsGetClientsProductsParams, -} from "./types/whmcs-api.types"; +} from "@customer-portal/domain/customer"; +import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions"; import type { WhmcsProductListResponse, } from "@customer-portal/domain/subscriptions"; @@ -129,15 +129,17 @@ export class WhmcsService { /** * Get client details by ID + * Returns internal WhmcsClient (type inferred) */ - async getClientDetails(clientId: number): Promise { + async getClientDetails(clientId: number) { return this.clientService.getClientDetails(clientId); } /** * Get client details by email + * Returns internal WhmcsClient (type inferred) */ - async getClientDetailsByEmail(email: string): Promise { + async getClientDetailsByEmail(email: string) { return this.clientService.getClientDetailsByEmail(email); } @@ -154,12 +156,16 @@ export class WhmcsService { /** * Convenience helpers for address get/update on WHMCS client */ - async getClientAddress(clientId: number): Promise { + async getClientAddress(clientId: number): Promise
{ const customer = await this.clientService.getClientDetails(clientId); - return customer.address ?? {}; + if (!customer || typeof customer !== 'object') { + return {} as Address; + } + const custWithAddress = customer as any; + return (custWithAddress.address || {}) as Address; } - async updateClientAddress(clientId: number, address: Partial): Promise { + async updateClientAddress(clientId: number, address: Partial
): Promise { const updateData = CustomerProviders.Whmcs.prepareWhmcsClientAddressUpdate(address); if (Object.keys(updateData).length === 0) return; await this.clientService.updateClient(clientId, updateData); diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index 4fae0d5a..02d0db1c 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -16,6 +16,8 @@ import { type LinkWhmcsRequest, type SetPasswordRequest, type ChangePasswordRequest, + type SsoLinkResponse, + type CheckPasswordNeededResponse, signupRequestSchema, validateSignupRequestSchema, linkWhmcsRequestSchema, @@ -327,7 +329,7 @@ export class AuthFacade { async createSsoLink( userId: string, destination?: string - ): Promise<{ url: string; expiresAt: string }> { + ): Promise { try { // Production-safe logging - no sensitive data this.logger.log("Creating SSO link request"); diff --git a/apps/bff/src/modules/auth/auth.types.ts b/apps/bff/src/modules/auth/auth.types.ts index a5d88fb2..acc54405 100644 --- a/apps/bff/src/modules/auth/auth.types.ts +++ b/apps/bff/src/modules/auth/auth.types.ts @@ -1,4 +1,4 @@ -import type { AuthenticatedUser } from "@customer-portal/domain/auth"; +import type { User } from "@customer-portal/domain/customer"; import type { Request } from "express"; -export type RequestWithUser = Request & { user: AuthenticatedUser }; +export type RequestWithUser = Request & { user: User }; diff --git a/apps/bff/src/modules/auth/infra/token/token.service.ts b/apps/bff/src/modules/auth/infra/token/token.service.ts index 28e9bb88..43e59d43 100644 --- a/apps/bff/src/modules/auth/infra/token/token.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token.service.ts @@ -9,7 +9,8 @@ import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; import { randomBytes, createHash } from "crypto"; -import type { AuthTokens, AuthenticatedUser } from "@customer-portal/domain/auth"; +import type { AuthTokens } from "@customer-portal/domain/auth"; +import type { User } from "@customer-portal/domain/customer"; import { UsersService } from "@bff/modules/users/users.service"; import { mapPrismaUserToDomain } from "@bff/infra/mappers"; @@ -195,7 +196,7 @@ export class AuthTokenService { deviceId?: string; userAgent?: string; } - ): Promise<{ tokens: AuthTokens; user: AuthenticatedUser }> { + ): Promise<{ tokens: AuthTokens; user: User }> { if (!refreshToken) { throw new UnauthorizedException("Invalid refresh token"); } diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts index 7e6b4b7f..abb263c2 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts @@ -12,17 +12,13 @@ import { AuthTokenService } from "../../token/token.service"; import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service"; import { type AuthTokens, - type UserProfile, + type PasswordChangeResult, type ChangePasswordRequest, changePasswordRequestSchema, } from "@customer-portal/domain/auth"; +import type { User } from "@customer-portal/domain/customer"; import { mapPrismaUserToDomain } from "@bff/infra/mappers"; -export interface PasswordChangeResult { - user: UserProfile; - tokens: AuthTokens; -} - @Injectable() export class PasswordWorkflowService { constructor( @@ -186,14 +182,8 @@ export class PasswordWorkflowService { throw new BadRequestException("Current password is incorrect"); } - if ( - newPassword.length < 8 || - !/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(newPassword) - ) { - throw new BadRequestException( - "Password must be at least 8 characters and include uppercase, lowercase, number, and special character." - ); - } + // Password validation is handled by changePasswordRequestSchema (uses passwordSchema from domain) + // No need for duplicate validation here const saltRoundsConfig = this.configService.get("BCRYPT_ROUNDS", 12); const saltRounds = diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts index d306d852..4c6b8137 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts @@ -21,10 +21,11 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { signupRequestSchema, type SignupRequest, + type SignupResult, type ValidateSignupRequest, type AuthTokens, - type UserProfile, } from "@customer-portal/domain/auth"; +import type { User } from "@customer-portal/domain/customer"; import { mapPrismaUserToDomain } from "@bff/infra/mappers"; import type { User as PrismaUser } from "@prisma/client"; @@ -33,11 +34,6 @@ type _SanitizedPrismaUser = Omit< "passwordHash" | "failedLoginAttempts" | "lockedUntil" >; -export interface SignupResult { - user: UserProfile; - tokens: AuthTokens; -} - @Injectable() export class SignupWorkflowService { constructor( diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts index 070338b5..52bf11d5 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts @@ -12,8 +12,8 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { mapPrismaUserToDomain } from "@bff/infra/mappers"; -import type { UserProfile } from "@customer-portal/domain/auth"; -import type { Customer } from "@customer-portal/domain/customer"; +import type { User } from "@customer-portal/domain/customer"; +// No direct Customer import - use inferred type from WHMCS service @Injectable() export class WhmcsLinkWorkflowService { @@ -44,7 +44,7 @@ export class WhmcsLinkWorkflowService { } try { - let clientDetails: Customer; + let clientDetails; // Type inferred from WHMCS service try { clientDetails = await this.whmcsService.getClientDetailsByEmail(email); } catch (error) { @@ -103,7 +103,7 @@ export class WhmcsLinkWorkflowService { throw new UnauthorizedException("Unable to verify credentials. Please try again later."); } - const customFields = clientDetails.customFields ?? {}; + const customFields = clientDetails.customfields ?? {}; // Raw WHMCS field name const customerNumber = customFields["198"]?.trim() ?? customFields["Customer Number"]?.trim(); if (!customerNumber) { @@ -136,8 +136,8 @@ export class WhmcsLinkWorkflowService { passwordHash: null, firstName: clientDetails.firstname || "", lastName: clientDetails.lastname || "", - company: clientDetails.companyName || "", - phone: clientDetails.phoneNumber || clientDetails.telephoneNumber || "", + company: clientDetails.companyname || "", // Raw WHMCS field name + phone: clientDetails.phonenumberformatted || clientDetails.phonenumber || clientDetails.telephoneNumber || "", // Raw WHMCS field names emailVerified: true, }); @@ -152,7 +152,7 @@ export class WhmcsLinkWorkflowService { throw new Error("Failed to load newly linked user"); } - const userProfile: UserProfile = mapPrismaUserToDomain(prismaUser); + const userProfile: User = mapPrismaUserToDomain(prismaUser); return { user: userProfile, diff --git a/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts b/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts index 68418501..8d73e625 100644 --- a/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts +++ b/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts @@ -2,7 +2,7 @@ import { Injectable, UnauthorizedException } from "@nestjs/common"; import { PassportStrategy } from "@nestjs/passport"; import { ExtractJwt, Strategy } from "passport-jwt"; import { ConfigService } from "@nestjs/config"; -import type { AuthenticatedUser } from "@customer-portal/domain/auth"; +import type { UserAuth } from "@customer-portal/domain/customer"; import { UsersService } from "@bff/modules/users/users.service"; import { mapPrismaUserToDomain } from "@bff/infra/mappers"; import type { Request } from "express"; @@ -45,7 +45,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { role: string; iat?: number; exp?: number; - }): Promise { + }): Promise { // Validate payload structure if (!payload.sub || !payload.email) { throw new Error("Invalid JWT payload"); diff --git a/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts b/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts index ce6cfde6..258fd0f7 100644 --- a/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts +++ b/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts @@ -1,7 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { CacheService } from "@bff/infra/cache/cache.service"; -import { UserIdMapping } from "../types/mapping.types"; +import type { UserIdMapping } from "@customer-portal/domain/mappings"; import { getErrorMessage } from "@bff/core/utils/error.util"; @Injectable() diff --git a/apps/bff/src/modules/id-mappings/mappings.service.ts b/apps/bff/src/modules/id-mappings/mappings.service.ts index d2e3d2b6..30875808 100644 --- a/apps/bff/src/modules/id-mappings/mappings.service.ts +++ b/apps/bff/src/modules/id-mappings/mappings.service.ts @@ -10,13 +10,13 @@ import { PrismaService } from "@bff/infra/database/prisma.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { MappingCacheService } from "./cache/mapping-cache.service"; import { MappingValidatorService } from "./validation/mapping-validator.service"; -import { +import type { UserIdMapping, CreateMappingRequest, UpdateMappingRequest, MappingSearchFilters, MappingStats, -} from "./types/mapping.types"; +} from "@customer-portal/domain/mappings"; import type { Prisma } from "@prisma/client"; import { mapPrismaMappingToDomain } from "@bff/infra/mappers"; diff --git a/apps/bff/src/modules/id-mappings/types/mapping.types.ts b/apps/bff/src/modules/id-mappings/types/mapping.types.ts index b36e2a39..85969d28 100644 --- a/apps/bff/src/modules/id-mappings/types/mapping.types.ts +++ b/apps/bff/src/modules/id-mappings/types/mapping.types.ts @@ -1,54 +1,11 @@ -import type { - UserIdMapping, - CreateMappingRequest, - UpdateMappingRequest, -} from "@customer-portal/domain/mappings"; +import type { UserIdMapping } from "@customer-portal/domain/mappings"; -// Re-export types from domain layer -export type { - UserIdMapping, - CreateMappingRequest, - UpdateMappingRequest, -} from "@customer-portal/domain/mappings"; - -export interface MappingSearchFilters { - userId?: string; - whmcsClientId?: number; - sfAccountId?: string; - hasWhmcsMapping?: boolean; - hasSfMapping?: boolean; -} - -// Validation result interface for service layer -export interface MappingValidationResult { - isValid: boolean; - errors: string[]; - warnings: string[]; -} - -export interface MappingStats { - totalMappings: number; - whmcsMappings: number; - salesforceMappings: number; - completeMappings: number; - orphanedMappings: number; -} - -export interface BulkMappingOperation { - operation: "create" | "update" | "delete"; - mappings: CreateMappingRequest[] | UpdateMappingRequest[] | string[]; -} - -export interface BulkMappingResult { - successful: number; - failed: number; - errors: Array<{ - index: number; - error: string; - data: unknown; - }>; -} +/** + * BFF-specific mapping types + * Business types and validation have been moved to domain layer + */ +// Infrastructure types for caching export interface MappingCacheKey { type: "userId" | "whmcsClientId" | "sfAccountId"; value: string | number; @@ -59,3 +16,6 @@ export interface CachedMapping { cachedAt: Date; ttl: number; } + +// Re-export validation result from domain for backward compatibility +export type { MappingValidationResult } from "@customer-portal/domain/mappings"; diff --git a/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts b/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts index f9c7e506..97ebab78 100644 --- a/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts +++ b/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts @@ -1,197 +1,82 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { z } from "zod"; import { - createMappingRequestSchema, - updateMappingRequestSchema, - userIdMappingSchema, type CreateMappingRequest, type UpdateMappingRequest, type UserIdMapping, + type MappingValidationResult, + validateCreateRequest, + validateUpdateRequest, + validateExistingMapping, + validateBulkMappings, + validateNoConflicts, + validateDeletion, + sanitizeCreateRequest, + sanitizeUpdateRequest, } from "@customer-portal/domain/mappings"; -// Legacy interface for backward compatibility -export interface MappingValidationResult { - isValid: boolean; - errors: string[]; - warnings: string[]; -} - +/** + * Mapping Validator Service + * + * Infrastructure service that wraps domain validation functions with logging. + * All business logic has been moved to @customer-portal/domain/mappings/validation. + */ @Injectable() export class MappingValidatorService { constructor(@Inject(Logger) private readonly logger: Logger) {} validateCreateRequest(request: CreateMappingRequest): MappingValidationResult { - const validationResult = createMappingRequestSchema.safeParse(request); - - if (validationResult.success) { - const warnings: string[] = []; - if (!request.sfAccountId) { - warnings.push("Salesforce account ID not provided - mapping will be incomplete"); - } - return { isValid: true, errors: [], warnings }; + const result = validateCreateRequest(request); + + if (!result.isValid) { + this.logger.warn({ request, errors: result.errors }, "Create mapping request validation failed"); } - - const errors = validationResult.error.issues.map(issue => issue.message); - this.logger.warn({ request, errors }, "Create mapping request validation failed"); - - return { isValid: false, errors, warnings: [] }; + + return result; } validateUpdateRequest(userId: string, request: UpdateMappingRequest): MappingValidationResult { - // First validate userId - const userIdValidation = z.string().uuid().safeParse(userId); - if (!userIdValidation.success) { - return { - isValid: false, - errors: ["User ID must be a valid UUID"], - warnings: [], - }; + const result = validateUpdateRequest(userId, request); + + if (!result.isValid) { + this.logger.warn({ userId, request, errors: result.errors }, "Update mapping request validation failed"); } - - // Then validate the update request - const validationResult = updateMappingRequestSchema.safeParse(request); - - if (validationResult.success) { - return { isValid: true, errors: [], warnings: [] }; - } - - const errors = validationResult.error.issues.map(issue => issue.message); - this.logger.warn({ userId, request, errors }, "Update mapping request validation failed"); - - return { isValid: false, errors, warnings: [] }; + + return result; } validateExistingMapping(mapping: UserIdMapping): MappingValidationResult { - const validationResult = userIdMappingSchema.safeParse(mapping); - - if (validationResult.success) { - const warnings: string[] = []; - if (!mapping.sfAccountId) { - warnings.push("Mapping is missing Salesforce account ID"); - } - return { isValid: true, errors: [], warnings }; + const result = validateExistingMapping(mapping); + + if (!result.isValid) { + this.logger.warn({ mapping, errors: result.errors }, "Existing mapping validation failed"); } - - const errors = validationResult.error.issues.map(issue => issue.message); - this.logger.warn({ mapping, errors }, "Existing mapping validation failed"); - - return { isValid: false, errors, warnings: [] }; + + return result; } validateBulkMappings( mappings: CreateMappingRequest[] ): Array<{ index: number; validation: MappingValidationResult }> { - return mappings.map((mapping, index) => ({ - index, - validation: this.validateCreateRequest(mapping), - })); + return validateBulkMappings(mappings); } validateNoConflicts( request: CreateMappingRequest, existingMappings: UserIdMapping[] ): MappingValidationResult { - const errors: string[] = []; - const warnings: string[] = []; - - // First validate the request format - const formatValidation = this.validateCreateRequest(request); - if (!formatValidation.isValid) { - return formatValidation; - } - - // Check for conflicts - const duplicateUser = existingMappings.find(m => m.userId === request.userId); - if (duplicateUser) { - errors.push(`User ${request.userId} already has a mapping`); - } - - const duplicateWhmcs = existingMappings.find(m => m.whmcsClientId === request.whmcsClientId); - if (duplicateWhmcs) { - errors.push( - `WHMCS client ${request.whmcsClientId} is already mapped to user ${duplicateWhmcs.userId}` - ); - } - - if (request.sfAccountId) { - const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId); - if (duplicateSf) { - warnings.push( - `Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}` - ); - } - } - - return { isValid: errors.length === 0, errors, warnings }; + return validateNoConflicts(request, existingMappings); } validateDeletion(mapping: UserIdMapping): MappingValidationResult { - const errors: string[] = []; - const warnings: string[] = []; - - if (!mapping) { - errors.push("Cannot delete non-existent mapping"); - return { isValid: false, errors, warnings }; - } - - // Validate the mapping format - const formatValidation = this.validateExistingMapping(mapping); - if (!formatValidation.isValid) { - return formatValidation; - } - - warnings.push( - "Deleting this mapping will prevent access to WHMCS/Salesforce data for this user" - ); - if (mapping.sfAccountId) { - warnings.push( - "This mapping includes Salesforce integration - deletion will affect case management" - ); - } - - return { isValid: true, errors, warnings }; + return validateDeletion(mapping); } sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest { - // Use Zod parsing to sanitize and validate - const validationResult = createMappingRequestSchema.safeParse({ - userId: request.userId?.trim(), - whmcsClientId: request.whmcsClientId, - sfAccountId: request.sfAccountId?.trim() || undefined, - }); - - if (validationResult.success) { - return validationResult.data; - } - - // Fallback to original behavior if validation fails - return { - userId: request.userId?.trim(), - whmcsClientId: request.whmcsClientId, - sfAccountId: request.sfAccountId?.trim() || undefined, - }; + return sanitizeCreateRequest(request); } sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest { - const sanitized: Partial = {}; - - if (request.whmcsClientId !== undefined) { - sanitized.whmcsClientId = request.whmcsClientId; - } - - if (request.sfAccountId !== undefined) { - sanitized.sfAccountId = request.sfAccountId?.trim() || undefined; - } - - // Use Zod parsing to validate the sanitized data - const validationResult = updateMappingRequestSchema.safeParse(sanitized); - - if (validationResult.success) { - return validationResult.data; - } - - // Fallback to sanitized data if validation fails - return sanitized; + return sanitizeUpdateRequest(request); } } diff --git a/apps/bff/src/modules/invoices/index.ts b/apps/bff/src/modules/invoices/index.ts index b0f1c8c0..a081dad0 100644 --- a/apps/bff/src/modules/invoices/index.ts +++ b/apps/bff/src/modules/invoices/index.ts @@ -1,20 +1,15 @@ -// Main orchestrator service -export { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service"; +/** + * Invoice Module Exports + */ -// Individual services -export { InvoiceRetrievalService } from "./services/invoice-retrieval.service"; -export { InvoiceHealthService } from "./services/invoice-health.service"; +export * from "./invoices.module"; +export * from "./invoices.controller"; +export * from "./services/invoices-orchestrator.service"; +export * from "./services/invoice-retrieval.service"; +export * from "./services/invoice-health.service"; -// Validators -export { InvoiceValidatorService } from "./validators/invoice-validator.service"; - -// Types +// Export monitoring types (infrastructure concerns) export type { - GetInvoicesOptions, - InvoiceValidationResult, - InvoiceServiceStats, InvoiceHealthStatus, - InvoiceStatus, - PaginationOptions, - UserMappingInfo, -} from "./types/invoice-service.types"; + InvoiceServiceStats, +} from "./types/invoice-monitoring.types"; diff --git a/apps/bff/src/modules/invoices/invoices.controller.ts b/apps/bff/src/modules/invoices/invoices.controller.ts index b5707b5e..3b4fe890 100644 --- a/apps/bff/src/modules/invoices/invoices.controller.ts +++ b/apps/bff/src/modules/invoices/invoices.controller.ts @@ -9,7 +9,6 @@ import { HttpCode, HttpStatus, BadRequestException, - UsePipes, } from "@nestjs/common"; import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; @@ -18,10 +17,16 @@ import { ZodValidationPipe } from "@bff/core/validation"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import type { Invoice, InvoiceList, InvoiceSsoLink, InvoiceListQuery } from "@customer-portal/domain/billing"; -import { invoiceListQuerySchema } from "@customer-portal/domain/billing"; +import { invoiceListQuerySchema, invoiceSchema } from "@customer-portal/domain/billing"; import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { PaymentMethodList, PaymentGatewayList, InvoicePaymentLink } from "@customer-portal/domain/payments"; +/** + * Invoice Controller + * + * All request validation is handled by Zod schemas via ZodValidationPipe. + * Business logic is delegated to service layer. + */ @Controller("invoices") export class InvoicesController { constructor( @@ -31,10 +36,9 @@ export class InvoicesController { ) {} @Get() - @UsePipes(new ZodValidationPipe(invoiceListQuerySchema)) async getInvoices( @Request() req: RequestWithUser, - @Query() query: InvoiceListQuery + @Query(new ZodValidationPipe(invoiceListQuerySchema)) query: InvoiceListQuery ): Promise { return this.invoicesService.getInvoices(req.user.id, query); } @@ -72,10 +76,8 @@ export class InvoicesController { @Request() req: RequestWithUser, @Param("id", ParseIntPipe) invoiceId: number ): Promise { - if (invoiceId <= 0) { - throw new BadRequestException("Invoice ID must be a positive number"); - } - + // Validate using domain schema + invoiceSchema.shape.id.parse(invoiceId); return this.invoicesService.getInvoiceById(req.user.id, invoiceId); } @@ -84,10 +86,9 @@ export class InvoicesController { @Request() _req: RequestWithUser, @Param("id", ParseIntPipe) invoiceId: number ): Subscription[] { - if (invoiceId <= 0) { - throw new BadRequestException("Invoice ID must be a positive number"); - } - + // Validate using domain schema + invoiceSchema.shape.id.parse(invoiceId); + // This functionality has been moved to WHMCS directly // For now, return empty array as subscriptions are managed in WHMCS return []; @@ -100,9 +101,8 @@ export class InvoicesController { @Param("id", ParseIntPipe) invoiceId: number, @Query("target") target?: "view" | "download" | "pay" ): Promise { - if (invoiceId <= 0) { - throw new BadRequestException("Invoice ID must be a positive number"); - } + // Validate using domain schema + invoiceSchema.shape.id.parse(invoiceId); // Validate target parameter if (target && !["view", "download", "pay"].includes(target)) { @@ -134,9 +134,8 @@ export class InvoicesController { @Query("paymentMethodId") paymentMethodId?: string, @Query("gatewayName") gatewayName?: string ): Promise { - if (invoiceId <= 0) { - throw new BadRequestException("Invoice ID must be a positive number"); - } + // Validate using domain schema + invoiceSchema.shape.id.parse(invoiceId); const paymentMethodIdNum = paymentMethodId ? parseInt(paymentMethodId, 10) : undefined; if (paymentMethodId && (isNaN(paymentMethodIdNum!) || paymentMethodIdNum! <= 0)) { diff --git a/apps/bff/src/modules/invoices/invoices.module.ts b/apps/bff/src/modules/invoices/invoices.module.ts index d79395a3..a9f3c55c 100644 --- a/apps/bff/src/modules/invoices/invoices.module.ts +++ b/apps/bff/src/modules/invoices/invoices.module.ts @@ -6,17 +6,20 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module"; import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service"; import { InvoiceRetrievalService } from "./services/invoice-retrieval.service"; import { InvoiceHealthService } from "./services/invoice-health.service"; -import { InvoiceValidatorService } from "./validators/invoice-validator.service"; +/** + * Invoice Module + * + * Validation is now handled by Zod schemas via ZodValidationPipe in controller. + * No separate validator service needed. + */ @Module({ imports: [WhmcsModule, MappingsModule], controllers: [InvoicesController], providers: [ - // New modular services InvoicesOrchestratorService, InvoiceRetrievalService, InvoiceHealthService, - InvoiceValidatorService, ], exports: [InvoicesOrchestratorService], }) diff --git a/apps/bff/src/modules/invoices/services/invoice-health.service.ts b/apps/bff/src/modules/invoices/services/invoice-health.service.ts index 388b8506..4da29444 100644 --- a/apps/bff/src/modules/invoices/services/invoice-health.service.ts +++ b/apps/bff/src/modules/invoices/services/invoice-health.service.ts @@ -3,7 +3,7 @@ import { Logger } from "nestjs-pino"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { InvoiceHealthStatus, InvoiceServiceStats } from "../types/invoice-service.types"; +import type { InvoiceHealthStatus, InvoiceServiceStats } from "../types/invoice-monitoring.types"; /** * Service responsible for health checks and monitoring of invoice services diff --git a/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts b/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts index 731301f4..46650fb7 100644 --- a/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts +++ b/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts @@ -5,45 +5,49 @@ import { Inject, } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { Invoice, InvoiceList } from "@customer-portal/domain/billing"; +import { + Invoice, + InvoiceList, + InvoiceListQuery, + InvoiceStatus, + invoiceSchema, + invoiceListQuerySchema, +} from "@customer-portal/domain/billing"; +import { validateUuidV4OrThrow } from "@customer-portal/domain/common"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import { InvoiceValidatorService } from "../validators/invoice-validator.service"; -import type { - GetInvoicesOptions, - InvoiceStatus, - PaginationOptions, - UserMappingInfo, -} from "../types/invoice-service.types"; + +interface UserMappingInfo { + userId: string; + whmcsClientId: number; +} /** * Service responsible for retrieving invoices from WHMCS + * + * Validation is handled by Zod schemas at the entry point (controller). + * This service focuses on business logic and data fetching. */ @Injectable() export class InvoiceRetrievalService { constructor( private readonly whmcsService: WhmcsService, private readonly mappingsService: MappingsService, - private readonly validator: InvoiceValidatorService, @Inject(Logger) private readonly logger: Logger ) {} /** * Get paginated invoices for a user + * @param userId - User ID (should be validated by controller) + * @param options - Query options (should be validated by controller using invoiceListQuerySchema) */ - async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise { - const { page = 1, limit = 10, status } = options; + async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise { + // Validate options against schema for internal calls + const validatedOptions = invoiceListQuerySchema.parse(options); + const { page = 1, limit = 10, status } = validatedOptions; try { - // Validate inputs - this.validator.validateUserId(userId); - this.validator.validatePagination({ page, limit }); - - if (status) { - this.validator.validateInvoiceStatus(status); - } - // Get user mapping const mapping = await this.getUserMapping(userId); @@ -78,12 +82,13 @@ export class InvoiceRetrievalService { /** * Get individual invoice by ID + * @param userId - User ID (should be validated by controller) + * @param invoiceId - Invoice ID (should be validated by controller/schema) */ async getInvoiceById(userId: string, invoiceId: number): Promise { try { - // Validate inputs - this.validator.validateUserId(userId); - this.validator.validateInvoiceId(invoiceId); + // Validate invoice ID using schema + invoiceSchema.shape.id.parse(invoiceId); // Get user mapping const mapping = await this.getUserMapping(userId); @@ -116,17 +121,14 @@ export class InvoiceRetrievalService { async getInvoicesByStatus( userId: string, status: InvoiceStatus, - options: PaginationOptions = {} + options: Partial = {} ): Promise { const { page = 1, limit = 10 } = options; try { - // Validate inputs - this.validator.validateUserId(userId); - this.validator.validateInvoiceStatus(status); - this.validator.validatePagination({ page, limit }); - - return await this.getInvoices(userId, { page, limit, status }); + // Cast to the subset of statuses supported by InvoiceListQuery + const queryStatus = status as "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; + return await this.getInvoices(userId, { page, limit, status: queryStatus }); } catch (error) { this.logger.error(`Failed to get ${status} invoices for user ${userId}`, { error: getErrorMessage(error), @@ -144,21 +146,21 @@ export class InvoiceRetrievalService { /** * Get unpaid invoices for a user */ - async getUnpaidInvoices(userId: string, options: PaginationOptions = {}): Promise { + async getUnpaidInvoices(userId: string, options: Partial = {}): Promise { return this.getInvoicesByStatus(userId, "Unpaid", options); } /** * Get overdue invoices for a user */ - async getOverdueInvoices(userId: string, options: PaginationOptions = {}): Promise { + async getOverdueInvoices(userId: string, options: Partial = {}): Promise { return this.getInvoicesByStatus(userId, "Overdue", options); } /** * Get paid invoices for a user */ - async getPaidInvoices(userId: string, options: PaginationOptions = {}): Promise { + async getPaidInvoices(userId: string, options: Partial = {}): Promise { return this.getInvoicesByStatus(userId, "Paid", options); } @@ -167,7 +169,7 @@ export class InvoiceRetrievalService { */ async getCancelledInvoices( userId: string, - options: PaginationOptions = {} + options: Partial = {} ): Promise { return this.getInvoicesByStatus(userId, "Cancelled", options); } @@ -177,7 +179,7 @@ export class InvoiceRetrievalService { */ async getCollectionsInvoices( userId: string, - options: PaginationOptions = {} + options: Partial = {} ): Promise { return this.getInvoicesByStatus(userId, "Collections", options); } @@ -186,13 +188,19 @@ export class InvoiceRetrievalService { * Get user mapping with validation */ private async getUserMapping(userId: string): Promise { + // Validate userId is a valid UUID + validateUuidV4OrThrow(userId); + const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { throw new NotFoundException("WHMCS client mapping not found"); } - this.validator.validateWhmcsClientId(mapping.whmcsClientId); + // WHMCS client ID validation - basic sanity check + if (mapping.whmcsClientId < 1) { + throw new NotFoundException("Invalid WHMCS client mapping"); + } return { userId, diff --git a/apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts b/apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts index 00420f64..29cbab74 100644 --- a/apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts +++ b/apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts @@ -1,16 +1,19 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { Invoice, InvoiceList } from "@customer-portal/domain/billing"; +import { + Invoice, + InvoiceList, + InvoiceListQuery, + InvoiceStatus, + INVOICE_PAGINATION, + VALID_INVOICE_STATUSES, +} from "@customer-portal/domain/billing"; import { InvoiceRetrievalService } from "./invoice-retrieval.service"; import { InvoiceHealthService } from "./invoice-health.service"; -import { InvoiceValidatorService } from "../validators/invoice-validator.service"; import type { - GetInvoicesOptions, - InvoiceStatus, - PaginationOptions, InvoiceHealthStatus, InvoiceServiceStats, -} from "../types/invoice-service.types"; +} from "../types/invoice-monitoring.types"; /** * Main orchestrator service for invoice operations @@ -21,7 +24,6 @@ export class InvoicesOrchestratorService { constructor( private readonly retrievalService: InvoiceRetrievalService, private readonly healthService: InvoiceHealthService, - private readonly validator: InvoiceValidatorService, @Inject(Logger) private readonly logger: Logger ) {} @@ -32,7 +34,7 @@ export class InvoicesOrchestratorService { /** * Get paginated invoices for a user */ - async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise { + async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise { const startTime = Date.now(); try { @@ -67,7 +69,7 @@ export class InvoicesOrchestratorService { async getInvoicesByStatus( userId: string, status: InvoiceStatus, - options: PaginationOptions = {} + options: Partial = {} ): Promise { const startTime = Date.now(); @@ -84,21 +86,21 @@ export class InvoicesOrchestratorService { /** * Get unpaid invoices for a user */ - async getUnpaidInvoices(userId: string, options: PaginationOptions = {}): Promise { + async getUnpaidInvoices(userId: string, options: Partial = {}): Promise { return this.retrievalService.getUnpaidInvoices(userId, options); } /** * Get overdue invoices for a user */ - async getOverdueInvoices(userId: string, options: PaginationOptions = {}): Promise { + async getOverdueInvoices(userId: string, options: Partial = {}): Promise { return this.retrievalService.getOverdueInvoices(userId, options); } /** * Get paid invoices for a user */ - async getPaidInvoices(userId: string, options: PaginationOptions = {}): Promise { + async getPaidInvoices(userId: string, options: Partial = {}): Promise { return this.retrievalService.getPaidInvoices(userId, options); } @@ -107,7 +109,7 @@ export class InvoicesOrchestratorService { */ async getCancelledInvoices( userId: string, - options: PaginationOptions = {} + options: Partial = {} ): Promise { return this.retrievalService.getCancelledInvoices(userId, options); } @@ -117,7 +119,7 @@ export class InvoicesOrchestratorService { */ async getCollectionsInvoices( userId: string, - options: PaginationOptions = {} + options: Partial = {} ): Promise { return this.retrievalService.getCollectionsInvoices(userId, options); } @@ -178,23 +180,19 @@ export class InvoicesOrchestratorService { } /** - * Validate get invoices options + * Get valid invoice statuses (from domain) */ - validateGetInvoicesOptions(options: GetInvoicesOptions) { - return this.validator.validateGetInvoicesOptions(options); + getValidStatuses(): readonly InvoiceStatus[] { + return VALID_INVOICE_STATUSES; } /** - * Get valid invoice statuses + * Get pagination limits (from domain) */ - getValidStatuses() { - return this.validator.getValidStatuses(); - } - - /** - * Get pagination limits - */ - getPaginationLimits() { - return this.validator.getPaginationLimits(); + getPaginationLimits(): { min: number; max: number } { + return { + min: INVOICE_PAGINATION.MIN_LIMIT, + max: INVOICE_PAGINATION.MAX_LIMIT, + }; } } diff --git a/apps/bff/src/modules/invoices/types/invoice-monitoring.types.ts b/apps/bff/src/modules/invoices/types/invoice-monitoring.types.ts new file mode 100644 index 00000000..1ca40be9 --- /dev/null +++ b/apps/bff/src/modules/invoices/types/invoice-monitoring.types.ts @@ -0,0 +1,27 @@ +/** + * BFF Invoice Monitoring Types + * + * Infrastructure types for monitoring, health checks, and statistics. + * These are BFF-specific and do not belong in the domain layer. + */ + +// Infrastructure monitoring types +export interface InvoiceServiceStats { + totalInvoicesRetrieved: number; + totalPaymentLinksCreated: number; + totalSsoLinksCreated: number; + averageResponseTime: number; + lastRequestTime?: Date; + lastErrorTime?: Date; +} + +export interface InvoiceHealthStatus { + status: "healthy" | "unhealthy"; + details: { + whmcsApi?: string; + mappingsService?: string; + error?: string; + timestamp: string; + }; +} + diff --git a/apps/bff/src/modules/invoices/types/invoice-service.types.ts b/apps/bff/src/modules/invoices/types/invoice-service.types.ts deleted file mode 100644 index b109f988..00000000 --- a/apps/bff/src/modules/invoices/types/invoice-service.types.ts +++ /dev/null @@ -1,41 +0,0 @@ -export interface GetInvoicesOptions { - page?: number; - limit?: number; - status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; -} - -export interface InvoiceValidationResult { - isValid: boolean; - errors: string[]; -} - -export interface InvoiceServiceStats { - totalInvoicesRetrieved: number; - totalPaymentLinksCreated: number; - totalSsoLinksCreated: number; - averageResponseTime: number; - lastRequestTime?: Date; - lastErrorTime?: Date; -} - -export interface InvoiceHealthStatus { - status: "healthy" | "unhealthy"; - details: { - whmcsApi?: string; - mappingsService?: string; - error?: string; - timestamp: string; - }; -} - -export type InvoiceStatus = "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; - -export interface PaginationOptions { - page?: number; - limit?: number; -} - -export interface UserMappingInfo { - userId: string; - whmcsClientId: number; -} diff --git a/apps/bff/src/modules/invoices/validators/invoice-validator.service.ts b/apps/bff/src/modules/invoices/validators/invoice-validator.service.ts deleted file mode 100644 index fdd2e0ad..00000000 --- a/apps/bff/src/modules/invoices/validators/invoice-validator.service.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { Injectable, BadRequestException } from "@nestjs/common"; -import { - INVOICE_PAGINATION, - VALID_INVOICE_STATUSES, - isValidInvoiceStatus, - sanitizePaginationLimit, - sanitizePaginationPage, -} from "@customer-portal/domain/billing"; -import type { - GetInvoicesOptions, - InvoiceValidationResult, - InvoiceStatus, - PaginationOptions, -} from "../types/invoice-service.types"; - -/** - * Service for validating invoice-related inputs and business rules - * - * Note: Validation constants have been moved to @customer-portal/domain/billing/constants - * This service now delegates to domain constants for consistency. - */ -@Injectable() -export class InvoiceValidatorService { - // Use domain constants instead of local definitions - private readonly validStatuses = VALID_INVOICE_STATUSES; - private readonly maxLimit = INVOICE_PAGINATION.MAX_LIMIT; - private readonly minLimit = INVOICE_PAGINATION.MIN_LIMIT; - - /** - * Validate invoice ID - */ - validateInvoiceId(invoiceId: number): void { - if (!invoiceId || invoiceId < 1) { - throw new BadRequestException("Invalid invoice ID"); - } - } - - /** - * Validate user ID - */ - validateUserId(userId: string): void { - if (!userId || typeof userId !== "string" || userId.trim().length === 0) { - throw new BadRequestException("Invalid user ID"); - } - } - - /** - * Validate pagination parameters - */ - validatePagination(options: PaginationOptions): void { - const { page = 1, limit = 10 } = options; - - if (page < 1) { - throw new BadRequestException("Page must be greater than 0"); - } - - if (limit < this.minLimit || limit > this.maxLimit) { - throw new BadRequestException(`Limit must be between ${this.minLimit} and ${this.maxLimit}`); - } - } - - /** - * Validate invoice status - */ - validateInvoiceStatus(status: string): InvoiceStatus { - if (!isValidInvoiceStatus(status)) { - throw new BadRequestException( - `Invalid status. Must be one of: ${this.validStatuses.join(", ")}` - ); - } - return status as InvoiceStatus; - } - - /** - * Validate get invoices options - */ - validateGetInvoicesOptions(options: GetInvoicesOptions): InvoiceValidationResult { - const errors: string[] = []; - - try { - this.validatePagination(options); - } catch (error) { - if (error instanceof BadRequestException) { - errors.push(error.message); - } - } - - if (options.status) { - try { - this.validateInvoiceStatus(options.status); - } catch (error) { - if (error instanceof BadRequestException) { - errors.push(error.message); - } - } - } - - return { - isValid: errors.length === 0, - errors, - }; - } - - /** - * Validate WHMCS client ID - */ - validateWhmcsClientId(clientId: number | undefined): void { - if (!clientId || clientId < 1) { - throw new BadRequestException("Invalid WHMCS client ID"); - } - } - - /** - * Validate payment gateway name - */ - validatePaymentGateway(gatewayName: string): void { - if (!gatewayName || typeof gatewayName !== "string" || gatewayName.trim().length === 0) { - throw new BadRequestException("Invalid payment gateway name"); - } - } - - /** - * Validate return URL for payment links - */ - validateReturnUrl(returnUrl: string): void { - if (!returnUrl || typeof returnUrl !== "string") { - throw new BadRequestException("Return URL is required"); - } - - try { - new URL(returnUrl); - } catch { - throw new BadRequestException("Invalid return URL format"); - } - } - - /** - * Get valid invoice statuses - */ - getValidStatuses(): readonly InvoiceStatus[] { - return this.validStatuses; - } - - /** - * Get pagination limits - */ - getPaginationLimits(): { min: number; max: number } { - return { - min: this.minLimit, - max: this.maxLimit, - }; - } - - /** - * Sanitize pagination options with defaults - * - * Note: Uses domain sanitization helpers for consistency - */ - sanitizePaginationOptions(options: PaginationOptions): Required { - const { page = 1, limit = 10 } = options; - - return { - page: sanitizePaginationPage(page), - limit: sanitizePaginationLimit(limit), - }; - } - - /** - * Check if status is a valid invoice status - * - * Note: Delegates to domain helper - */ - isValidStatus(status: string): status is InvoiceStatus { - return isValidInvoiceStatus(status); - } -} diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index 66082556..6c12ca0e 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -21,7 +21,6 @@ import { type SalesforceOrderItemRecord, Providers as OrderProviders, } from "@customer-portal/domain/orders"; -import type { FulfillmentOrderDetails, FulfillmentOrderItem } from "../types/fulfillment.types"; export interface OrderItemMappingResult { whmcsItems: any[]; @@ -44,7 +43,7 @@ export interface OrderFulfillmentContext { sfOrderId: string; idempotencyKey: string; validation: OrderFulfillmentValidationResult | null; - orderDetails?: FulfillmentOrderDetails; + orderDetails?: OrderDetails; mappingResult?: OrderItemMappingResult; whmcsResult?: WhmcsOrderResult; steps: OrderFulfillmentStep[]; @@ -125,7 +124,7 @@ export class OrderFulfillmentOrchestrator { if (!orderDetails) { throw new Error("Order details could not be retrieved."); } - context.orderDetails = this.mapOrderDetails(orderDetails); + context.orderDetails = orderDetails; } catch (error) { this.logger.error("Failed to get order details", { sfOrderId, @@ -175,7 +174,7 @@ export class OrderFulfillmentOrchestrator { return Promise.reject(new Error("Order details are required for mapping")); } // Use domain mapper directly - single transformation! - const result = OrderProviders.Whmcs.mapFulfillmentOrderItems(context.orderDetails.items); + const result = OrderProviders.Whmcs.mapOrderToWhmcsItems(context.orderDetails); mappingResult = result; this.logger.log("OrderItems mapped to WHMCS", { @@ -199,7 +198,7 @@ export class OrderFulfillmentOrchestrator { throw new Error("Mapping result is not available"); } - const orderNotes = this.orderWhmcsMapper.createOrderNotes( + const orderNotes = OrderProviders.Whmcs.createOrderNotes( sfOrderId, `Provisioned from Salesforce Order ${sfOrderId}` ); @@ -338,185 +337,6 @@ export class OrderFulfillmentOrchestrator { return context; } - /** - * Legacy fulfillment method (kept for backward compatibility) - */ - private async executeFulfillmentLegacy( - sfOrderId: string, - payload: Record, - idempotencyKey: string - ): Promise { - const context: OrderFulfillmentContext = { - sfOrderId, - idempotencyKey, - validation: null, - steps: this.initializeSteps( - typeof payload.orderType === "string" ? payload.orderType : "Unknown" - ), - }; - - this.logger.log("Starting fulfillment orchestration", { - sfOrderId, - idempotencyKey, - }); - - try { - // Step 1: Validate fulfillment request - await this.executeStep(context, "validation", async () => { - context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( - sfOrderId, - idempotencyKey - ); - }); - - if (!context.validation) { - throw new Error("Fulfillment validation did not complete successfully"); - } - - // If already provisioned, return early - if (context.validation.isAlreadyProvisioned) { - this.markStepCompleted(context, "validation"); - this.markStepsSkipped(context, [ - "sf_status_update", - "order_details", - "mapping", - "whmcs_create", - "whmcs_accept", - "sf_success_update", - ]); - return context; - } - - // Step 2: Update Salesforce status to "Activating" - await this.executeStep(context, "sf_status_update", async () => { - - await this.salesforceService.updateOrder({ - Id: sfOrderId, - Activation_Status__c: "Activating", - }); - }); - - // Step 3: Get order details with items - await this.executeStep(context, "order_details", async () => { - const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId); - if (!orderDetails) { - // Do not expose sensitive info in error - throw new Error("Order details could not be retrieved."); - } - context.orderDetails = this.mapOrderDetails(orderDetails); - }); - - // Step 4: Map OrderItems to WHMCS format - await this.executeStep(context, "mapping", async () => { - if (!context.orderDetails) { - throw new Error("Order details are required for mapping"); - } - - context.mappingResult = this.orderWhmcsMapper.mapOrderItemsToWhmcs( - context.orderDetails.items - ); - - // Validate mapped items - this.orderWhmcsMapper.validateMappedItems(context.mappingResult.whmcsItems); - // keep async signature for executeStep typing and lint rule - await Promise.resolve(); - }); - - // Step 5: Create order in WHMCS - await this.executeStep(context, "whmcs_create", async () => { - if (!context.validation) { - throw new Error("Validation context is missing"); - } - - const mappingResult = context.mappingResult; - if (!mappingResult) { - throw new Error("Mapping result is not available"); - } - - const orderNotes = this.orderWhmcsMapper.createOrderNotes( - sfOrderId, - `Provisioned from Salesforce Order ${sfOrderId}` - ); - - const createResult = await this.whmcsOrderService.addOrder({ - clientId: context.validation.clientId, - items: mappingResult.whmcsItems, - paymentMethod: "stripe", // Use Stripe for provisioning orders - promoCode: "1st Month Free (Monthly Plan)", - sfOrderId, - notes: orderNotes, - // Align with Salesforce implementation: suppress invoice email and all emails - // Keep invoice generation enabled (noinvoice=false) unless changed later - noinvoiceemail: true, - noemail: true, - }); - - context.whmcsResult = { - orderId: createResult.orderId, - serviceIds: [], - }; - }); - - // Step 6: Accept/provision order in WHMCS - await this.executeStep(context, "whmcs_accept", async () => { - if (!context.whmcsResult) { - throw new Error("WHMCS result missing before acceptance step"); - } - - const acceptResult = await this.whmcsOrderService.acceptOrder( - context.whmcsResult.orderId, - sfOrderId - ); - - // Update context with complete WHMCS result - context.whmcsResult = acceptResult; - }); - - // Step 7: SIM-specific fulfillment (if applicable) - if (context.orderDetails?.orderType === "SIM") { - await this.executeStep(context, "sim_fulfillment", async () => { - if (!context.orderDetails) { - throw new Error("Order details are required for SIM fulfillment"); - } - - // Extract configurations from the original payload - const configurations = this.extractConfigurations(payload.configurations); - - await this.simFulfillmentService.fulfillSimOrder({ - orderDetails: context.orderDetails, - configurations, - }); - }); - } - - // Step 8: Update Salesforce with success - await this.executeStep(context, "sf_success_update", async () => { - - await this.salesforceService.updateOrder({ - Id: sfOrderId, - Status: "Completed", - Activation_Status__c: "Activated", - WHMCS_Order_ID__c: context.whmcsResult!.orderId.toString(), - }); - }); - - this.logger.log("Fulfillment orchestration completed successfully", { - sfOrderId, - whmcsOrderId: context.whmcsResult?.orderId, - serviceCount: context.whmcsResult?.serviceIds.length || 0, - totalSteps: context.steps.length, - completedSteps: context.steps.filter(s => s.status === "completed").length, - }); - - return context; - } catch (error) { - await this.handleFulfillmentError( - context, - error instanceof Error ? error : new Error(String(error)) - ); - throw error; - } - } /** * Initialize fulfillment steps @@ -540,76 +360,6 @@ export class OrderFulfillmentOrchestrator { return steps; } - /** - * Execute a single fulfillment step with error handling - */ - private async executeStep( - context: OrderFulfillmentContext, - stepName: string, - stepFunction: () => Promise - ): Promise { - const step = context.steps.find(s => s.step === stepName); - if (!step) { - throw new Error(`Step ${stepName} not found in context`); - } - - step.status = "in_progress"; - step.startedAt = new Date(); - - this.logger.log(`Executing fulfillment step: ${stepName}`, { - sfOrderId: context.sfOrderId, - step: stepName, - }); - - try { - await stepFunction(); - - step.status = "completed"; - step.completedAt = new Date(); - - this.logger.log(`Fulfillment step completed: ${stepName}`, { - sfOrderId: context.sfOrderId, - step: stepName, - duration: step.completedAt.getTime() - step.startedAt.getTime(), - }); - } catch (error) { - step.status = "failed"; - step.completedAt = new Date(); - step.error = getErrorMessage(error); - - this.logger.error(`Fulfillment step failed: ${stepName}`, { - sfOrderId: context.sfOrderId, - step: stepName, - error: step.error, - }); - - throw error; - } - } - - /** - * Mark step as completed (for skipped steps) - */ - private markStepCompleted(context: OrderFulfillmentContext, stepName: string): void { - const step = context.steps.find(s => s.step === stepName); - if (step) { - step.status = "completed"; - step.completedAt = new Date(); - } - } - - /** - * Mark multiple steps as skipped - */ - private markStepsSkipped(context: OrderFulfillmentContext, stepNames: string[]): void { - stepNames.forEach(stepName => { - const step = context.steps.find(s => s.step === stepName); - if (step) { - step.status = "completed"; // Mark as completed since they're not needed - step.completedAt = new Date(); - } - }); - } private extractConfigurations(value: unknown): Record { if (value && typeof value === "object") { @@ -618,64 +368,6 @@ export class OrderFulfillmentOrchestrator { return {}; } - private mapOrderDetails(order: OrderDetails): FulfillmentOrderDetails { - const orderRecord = order as unknown as Record; - const rawItems = orderRecord.items; - const itemsSource = Array.isArray(rawItems) ? rawItems : []; - - const items: FulfillmentOrderItem[] = itemsSource.map(item => { - if (!item || typeof item !== "object") { - throw new Error("Invalid order item structure received from Salesforce"); - } - - const record = item as Record; - const productRaw = record.product; - const product = - productRaw && typeof productRaw === "object" - ? (productRaw as Record) - : null; - - const id = typeof record.id === "string" ? record.id : ""; - const orderId = typeof record.orderId === "string" ? record.orderId : ""; - const quantity = typeof record.quantity === "number" ? record.quantity : 0; - - if (!id || !orderId) { - throw new Error("Order item is missing identifier information"); - } - - return { - id, - orderId, - quantity, - product: product - ? { - id: typeof product.id === "string" ? product.id : undefined, - sku: typeof product.sku === "string" ? product.sku : undefined, - itemClass: typeof product.itemClass === "string" ? product.itemClass : undefined, - whmcsProductId: - typeof product.whmcsProductId === "string" ? product.whmcsProductId : undefined, - billingCycle: - typeof product.billingCycle === "string" ? product.billingCycle : undefined, - } - : null, - } satisfies FulfillmentOrderItem; - }); - - const orderIdRaw = orderRecord.id; - const orderId = typeof orderIdRaw === "string" ? orderIdRaw : undefined; - if (!orderId) { - throw new Error("Order record is missing an id"); - } - - const rawOrderType = orderRecord.orderType; - const orderType = typeof rawOrderType === "string" ? rawOrderType : undefined; - - return { - id: orderId, - orderType, - items, - }; - } /** * Handle fulfillment errors and update Salesforce diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts index 63cf7098..6b7b1e7b 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts @@ -1,5 +1,6 @@ import { Injectable, BadRequestException, ConflictException, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; +import { z } from "zod"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; @@ -8,6 +9,9 @@ import type { SalesforceOrderRecord } from "@customer-portal/domain/orders"; import { sfOrderIdParamSchema } from "@customer-portal/domain/orders"; type OrderStringFieldKey = "activationStatus"; +// Schema for validating Salesforce Account ID +const salesforceAccountIdSchema = z.string().min(1, "Salesforce AccountId is required"); + export interface OrderFulfillmentValidationResult { sfOrder: SalesforceOrderRecord; clientId: number; @@ -65,10 +69,8 @@ export class OrderFulfillmentValidator { } // 3. Get WHMCS client mapping - const accountId = sfOrder.AccountId; - if (typeof accountId !== "string" || accountId.length === 0) { - throw new BadRequestException("Salesforce order is missing AccountId"); - } + // Validate AccountId using schema instead of manual type checks + const accountId = salesforceAccountIdSchema.parse(sfOrder.AccountId); const mapping = await this.mappingsService.findBySfAccountId(accountId); if (!mapping?.whmcsClientId) { throw new BadRequestException(`No WHMCS client mapping found for account ${accountId}`); diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index a6dc4dad..b4820f40 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -16,7 +16,9 @@ import { hasVpnActivationFee, hasInternetServicePlan, } from "@customer-portal/domain/orders"; -import type { WhmcsProduct } from "@bff/integrations/whmcs/types/whmcs-api.types"; +import type { Providers } from "@customer-portal/domain/subscriptions"; + +type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw; import { OrderPricebookService } from "./order-pricebook.service"; /** diff --git a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts index 88647ccc..49fc3f8c 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -1,11 +1,11 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service"; -import type { FulfillmentOrderDetails, FulfillmentOrderItem } from "../types/fulfillment.types"; +import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders"; import { getErrorMessage } from "@bff/core/utils/error.util"; export interface SimFulfillmentRequest { - orderDetails: FulfillmentOrderDetails; + orderDetails: OrderDetails; configurations: Record; } @@ -33,7 +33,7 @@ export class SimFulfillmentService { const mnp = this.extractMnpConfig(configurations); const simPlanItem = orderDetails.items.find( - (item: FulfillmentOrderItem) => + (item: OrderItemDetails) => item.product?.itemClass === "Plan" || item.product?.sku?.toLowerCase().includes("sim") ); diff --git a/apps/bff/src/modules/orders/types/fulfillment.types.ts b/apps/bff/src/modules/orders/types/fulfillment.types.ts deleted file mode 100644 index 57229b35..00000000 --- a/apps/bff/src/modules/orders/types/fulfillment.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type { - FulfillmentOrderProduct, - FulfillmentOrderItem, - FulfillmentOrderDetails, -} from "@customer-portal/domain/orders"; - -export { - fulfillmentOrderProductSchema, - fulfillmentOrderItemSchema, - fulfillmentOrderDetailsSchema, -} from "@customer-portal/domain/orders"; diff --git a/apps/bff/src/modules/subscriptions/sim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management.service.ts index 8983fb5f..d9de8b2a 100644 --- a/apps/bff/src/modules/subscriptions/sim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management.service.ts @@ -8,7 +8,7 @@ import type { SimCancelRequest, SimTopUpHistoryRequest, SimFeaturesUpdateRequest, -} from "./sim-management/types/sim-requests.types"; +} from "@customer-portal/domain/sim"; import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface"; @Injectable() diff --git a/apps/bff/src/modules/subscriptions/sim-management/index.ts b/apps/bff/src/modules/subscriptions/sim-management/index.ts index 692f0623..0e850c62 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/index.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/index.ts @@ -9,14 +9,14 @@ export { EsimManagementService } from "./services/esim-management.service"; export { SimValidationService } from "./services/sim-validation.service"; export { SimNotificationService } from "./services/sim-notification.service"; -// Types +// Types (re-export from domain for module convenience) export type { SimTopUpRequest, SimPlanChangeRequest, SimCancelRequest, SimTopUpHistoryRequest, SimFeaturesUpdateRequest, -} from "./types/sim-requests.types"; +} from "@customer-portal/domain/sim"; // Interfaces export type { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index d35fe3c6..865021b2 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts @@ -4,7 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f import { SimValidationService } from "./sim-validation.service"; import { SimNotificationService } from "./sim-notification.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { SimCancelRequest } from "../types/sim-requests.types"; +import type { SimCancelRequest } from "@customer-portal/domain/sim"; @Injectable() export class SimCancellationService { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts index 86bbc993..78470a7a 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts @@ -15,7 +15,7 @@ import type { SimCancelRequest, SimTopUpHistoryRequest, SimFeaturesUpdateRequest, -} from "../types/sim-requests.types"; +} from "@customer-portal/domain/sim"; @Injectable() export class SimOrchestratorService { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts index 99bbd0b9..42330c51 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts @@ -4,7 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f import { SimValidationService } from "./sim-validation.service"; import { SimNotificationService } from "./sim-notification.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "../types/sim-requests.types"; +import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "@customer-portal/domain/sim"; @Injectable() export class SimPlanService { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts index 69aaae22..7c9d3841 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts @@ -6,7 +6,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { SimValidationService } from "./sim-validation.service"; import { SimNotificationService } from "./sim-notification.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { SimTopUpRequest } from "../types/sim-requests.types"; +import type { SimTopUpRequest } from "@customer-portal/domain/sim"; @Injectable() export class SimTopUpService { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts index 4e6bfd98..a93c3f7d 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts @@ -5,7 +5,7 @@ import { SimValidationService } from "./sim-validation.service"; import { SimUsageStoreService } from "../../sim-usage-store.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim"; -import type { SimTopUpHistoryRequest } from "../types/sim-requests.types"; +import type { SimTopUpHistoryRequest } from "@customer-portal/domain/sim"; import { BadRequestException } from "@nestjs/common"; @Injectable() diff --git a/apps/bff/src/modules/subscriptions/sim-management/types/sim-requests.types.ts b/apps/bff/src/modules/subscriptions/sim-management/types/sim-requests.types.ts deleted file mode 100644 index cd371dfd..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/types/sim-requests.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type { - SimTopUpRequest, - SimPlanChangeRequest, - SimCancelRequest, - SimTopUpHistoryRequest, - SimFeaturesUpdateRequest, -} from "@customer-portal/domain/sim"; - -export { - simTopUpRequestSchema, - simPlanChangeRequestSchema, - simCancelRequestSchema, - simTopUpHistoryRequestSchema, - simFeaturesUpdateRequestSchema, -} from "@customer-portal/domain/sim"; diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index 40c7519d..a78e37d7 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -16,6 +16,9 @@ import { SimManagementService } from "./sim-management.service"; import { Subscription, SubscriptionList, + SubscriptionStats, + SimActionResponse, + SimPlanChangeResult, subscriptionQuerySchema, type SubscriptionQuery, } from "@customer-portal/domain/subscriptions"; @@ -63,12 +66,7 @@ export class SubscriptionsController { } @Get("stats") - async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{ - total: number; - active: number; - completed: number; - cancelled: number; - }> { + async getSubscriptionStats(@Request() req: RequestWithUser): Promise { return this.subscriptionsService.getSubscriptionStats(req.user.id); } @@ -155,7 +153,7 @@ export class SubscriptionsController { @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, @Body() body: SimTopupRequest - ) { + ): Promise { await this.simManagementService.topUpSim(req.user.id, subscriptionId, body); return { success: true, message: "SIM top-up completed successfully" }; } @@ -166,7 +164,7 @@ export class SubscriptionsController { @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, @Body() body: SimChangePlanRequest - ) { + ): Promise { const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body); return { success: true, @@ -181,7 +179,7 @@ export class SubscriptionsController { @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, @Body() body: SimCancelRequest - ) { + ): Promise { await this.simManagementService.cancelSim(req.user.id, subscriptionId, body); return { success: true, message: "SIM cancellation completed successfully" }; } @@ -191,7 +189,7 @@ export class SubscriptionsController { @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, @Body() body: { newEid?: string } = {} - ) { + ): Promise { await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body.newEid); return { success: true, message: "eSIM profile reissue completed successfully" }; } @@ -202,7 +200,7 @@ export class SubscriptionsController { @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, @Body() body: SimFeaturesRequest - ) { + ): Promise { await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body); return { success: true, message: "SIM features updated successfully" }; } diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index 11c46c26..60e0fe9f 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -7,7 +7,9 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { Logger } from "nestjs-pino"; import { z } from "zod"; import { subscriptionSchema } from "@customer-portal/domain/subscriptions"; -import type { WhmcsProduct } from "@bff/integrations/whmcs/types/whmcs-api.types"; +import type { Providers } from "@customer-portal/domain/subscriptions"; + +type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw; export interface GetSubscriptionsOptions { status?: string; diff --git a/apps/bff/src/modules/users/users.service.ts b/apps/bff/src/modules/users/users.service.ts index 196bd824..c339a76c 100644 --- a/apps/bff/src/modules/users/users.service.ts +++ b/apps/bff/src/modules/users/users.service.ts @@ -2,13 +2,13 @@ import { Injectable, Inject, NotFoundException, BadRequestException } from "@nes import { Logger } from "nestjs-pino"; import type { User as PrismaUser } from "@prisma/client"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@bff/core/utils/validation.util"; +import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@customer-portal/domain/common"; import { PrismaService } from "@bff/infra/database/prisma.service"; import { updateCustomerProfileRequestSchema, - type AuthenticatedUser, type UpdateCustomerProfileRequest, } from "@customer-portal/domain/auth"; +import { combineToUser, Providers as CustomerProviders, type User } from "@customer-portal/domain/customer"; import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { Invoice } from "@customer-portal/domain/billing"; import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard"; @@ -48,7 +48,7 @@ export class UsersService { /** * Find user by email - returns authenticated user with full profile from WHMCS */ - async findByEmail(email: string): Promise { + async findByEmail(email: string): Promise { const validEmail = this.validateEmail(email); try { @@ -101,7 +101,7 @@ export class UsersService { /** * Get user profile - primary method for fetching authenticated user with full WHMCS data */ - async findById(id: string): Promise { + async findById(id: string): Promise { const validId = this.validateUserId(id); try { @@ -123,7 +123,7 @@ export class UsersService { * Get complete customer profile from WHMCS (single source of truth) * Includes profile fields + address + auth state */ - async getProfile(userId: string): Promise { + async getProfile(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new NotFoundException("User not found"); @@ -133,35 +133,14 @@ export class UsersService { } try { - // Fetch complete client data from WHMCS (source of truth) - const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId); - - // Map WHMCS client to CustomerProfile with auth state - const profile: AuthenticatedUser = { - id: user.id, - email: client.email, - firstname: client.firstname || null, - lastname: client.lastname || null, - fullname: client.fullname || null, - companyname: client.companyName || null, - phonenumber: client.phoneNumber || null, - language: client.language || null, - currencyCode: client.currencyCode || null, - - // Address from WHMCS - address: client.address || undefined, - - // Auth state from portal DB - role: user.role, - emailVerified: user.emailVerified, - mfaEnabled: user.mfaSecret !== null, - lastLoginAt: user.lastLoginAt?.toISOString(), - - createdAt: user.createdAt.toISOString(), - updatedAt: user.updatedAt.toISOString(), - }; - - return profile; + // Get WHMCS client data (source of truth for profile) + const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId); + + // Map Prisma user to UserAuth + const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user); + + // Domain combines UserAuth + WhmcsClient → User + return combineToUser(userAuth, whmcsClient as CustomerProviders.Whmcs.WhmcsClient); } catch (error) { this.logger.error("Failed to fetch client profile from WHMCS", { error: getErrorMessage(error), @@ -175,7 +154,7 @@ export class UsersService { /** * Create user (auth state only in portal DB) */ - async create(userData: Partial): Promise { + async create(userData: Partial): Promise { const validEmail = this.validateEmail(userData.email!); try { @@ -198,7 +177,7 @@ export class UsersService { * Update user auth state (password, login attempts, etc.) * For profile updates, use updateProfile instead */ - async update(id: string, userData: UserUpdateData): Promise { + async update(id: string, userData: UserUpdateData): Promise { const validId = this.validateUserId(id); const sanitizedData = this.sanitizeUserData(userData); @@ -222,7 +201,7 @@ export class UsersService { * Update customer profile in WHMCS (single source of truth) * Can update profile fields AND/OR address fields in one call */ - async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise { + async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise { const validId = this.validateUserId(userId); const parsed = updateCustomerProfileRequestSchema.parse(update); @@ -466,7 +445,12 @@ export class UsersService { let currency = "JPY"; // Default try { const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId); - currency = client.currencyCode || currency; + if (client && typeof client === 'object' && 'currency_code' in client) { + const currencyCode = (client as any).currency_code; + if (currencyCode) { + currency = currencyCode; + } + } } catch (error) { this.logger.warn("Could not fetch currency from WHMCS client", { userId }); } diff --git a/bff-validation-migration.md b/bff-validation-migration.md new file mode 100644 index 00000000..7abd3c32 --- /dev/null +++ b/bff-validation-migration.md @@ -0,0 +1,137 @@ +# BFF Business Validation Migration to Domain + +## Investigation Summary + +Found the following validator services in BFF: +1. ✅ **MappingValidatorService** - Already migrated to domain +2. ✅ **OrderValidator** - Already uses domain validation helpers +3. 🔄 **InvoiceValidatorService** - Has pure validation functions to migrate +4. ❌ **OrderFulfillmentValidator** - Pure infrastructure (DB/API calls) +5. ❌ **SimValidationService** - Pure infrastructure (DB lookups) + +## Candidates for Migration + +### 1. Invoice Validation Functions → `domain/billing/validation.ts` + +**Source**: `apps/bff/src/modules/invoices/validators/invoice-validator.service.ts` + +**Pure validation functions to migrate**: + +```typescript +// Input validation (no dependencies) +- validateInvoiceId(invoiceId: number): ValidationResult +- validateUserId(userId: string): ValidationResult +- validateWhmcsClientId(clientId: number): ValidationResult +- validatePaymentGateway(gatewayName: string): ValidationResult +- validateReturnUrl(returnUrl: string): ValidationResult +- validatePagination(options): ValidationResult +- validateGetInvoicesOptions(options): InvoiceValidationResult +``` + +**Already in domain** (correctly placed): +- `INVOICE_PAGINATION` constants ✅ +- `VALID_INVOICE_STATUSES` ✅ +- `isValidInvoiceStatus()` ✅ +- `sanitizePaginationLimit()` ✅ +- `sanitizePaginationPage()` ✅ + +### 2. Invoice Validation Result Type → `domain/billing/contract.ts` + +**Source**: `apps/bff/src/modules/invoices/types/invoice-service.types.ts` + +```typescript +export interface InvoiceValidationResult { + isValid: boolean; + errors: string[]; +} +``` + +### Status Check: Orders & Subscriptions + +**Orders Validation** ✅ Already good! +- Business rules already in `domain/orders/validation.ts` +- Helpers: `getOrderTypeValidationError`, `hasSimServicePlan`, etc. +- BFF only does infrastructure (DB, API calls, logging) + +**Subscriptions/SIM Validation** ✅ Already good! +- `SimValidationService` is pure infrastructure (fetches from DB, checks subscription type) +- No pure business logic to extract + +## Migration Plan + +### Phase 1: Invoice Validation + +1. Create `packages/domain/billing/validation.ts` with pure validation functions +2. Add `InvoiceValidationResult` to `packages/domain/billing/contract.ts` +3. Update `packages/domain/billing/index.ts` to export validation +4. Refactor `InvoiceValidatorService` to delegate to domain functions +5. Add logging wrapper (infrastructure concern) in BFF service + +### Phase 2: Common URL Validation + +The `validateReturnUrl()` function is generic enough for `domain/common/validation.ts`: + +```typescript +export function validateUrl(url: string): ValidationResult { + try { + new URL(url); + return { isValid: true, errors: [] }; + } catch { + return { isValid: false, errors: ["Invalid URL format"] }; + } +} +``` + +## Architecture Pattern (Confirmed Correct) + +``` +┌─────────────────────────────────────────────┐ +│ Domain Layer (Pure Business Logic) │ +├─────────────────────────────────────────────┤ +│ - domain/*/schema.ts (Zod schemas) │ +│ - domain/*/validation.ts (pure functions) │ +│ - domain/*/contract.ts (types) │ +│ - domain/common/validation.ts (helpers) │ +└─────────────────────────────────────────────┘ + ↑ + │ imports + │ +┌─────────────────────────────────────────────┐ +│ BFF Infrastructure Layer │ +├─────────────────────────────────────────────┤ +│ - *-validator.service.ts (thin wrappers) │ +│ - Database queries │ +│ - HTTP/API calls │ +│ - Logging │ +│ - Caching │ +│ - Error handling │ +└─────────────────────────────────────────────┘ +``` + +## What Stays in BFF (Infrastructure) + +These are correctly placed and should NOT be moved: + +1. **Database-dependent validation** + - `validateUserMapping()` - queries DB for mappings + - `validatePaymentMethod()` - queries WHMCS API + - `validateSKUs()` - queries Salesforce + - `validateInternetDuplication()` - queries WHMCS for existing products + - `validateSimSubscription()` - queries DB for subscription details + +2. **Service orchestration** + - `validateCompleteOrder()` - orchestrates multiple validations + - `validateFulfillmentRequest()` - orchestrates SF + payment checks + +3. **Infrastructure types** + - `InvoiceServiceStats` (monitoring) + - `InvoiceHealthStatus` (health checks) + - `MappingCacheKey`, `CachedMapping` (caching) + +## Implementation Order + +1. ✅ Common validation utilities (completed) +2. ✅ Mapping validation (completed) +3. 🔄 **Next**: Invoice validation functions +4. Document pattern in architecture guide + diff --git a/docs/VALIDATION_PATTERNS.md b/docs/VALIDATION_PATTERNS.md new file mode 100644 index 00000000..2dee51ba --- /dev/null +++ b/docs/VALIDATION_PATTERNS.md @@ -0,0 +1,413 @@ +# Validation Patterns Guide + +## Overview + +This guide establishes consistent validation patterns across the customer portal codebase. Follow these patterns to ensure maintainable, DRY (Don't Repeat Yourself) validation logic. + +## Core Principles + +1. **Schema-First Validation**: Define validation rules in Zod schemas, not in imperative code +2. **Single Source of Truth**: Each validation rule should be defined once and reused +3. **Layer Separation**: Keep format validation in schemas, business logic in validation functions +4. **No Duplicate Logic**: Never duplicate validation between schemas and manual checks + +## Architecture + +### Domain Layer (`packages/domain/`) + +Each domain should have: + +- **`schema.ts`**: Zod schemas for runtime type validation +- **`validation.ts`**: Business validation functions (using schemas internally) +- **`contract.ts`**: TypeScript types (inferred from schemas) + +#### Example Structure: + +``` +packages/domain/orders/ +├── schema.ts # Zod schemas (format validation) +├── validation.ts # Business validation functions +├── contract.ts # TypeScript types +└── index.ts # Public exports +``` + +### BFF Layer (`apps/bff/src/`) + +- **Controllers**: Use `@UsePipes(ZodValidationPipe)` for request validation +- **Services**: Call domain validation functions (not schemas directly) +- **Validators**: Infrastructure-dependent validation only (DB checks, API calls) + +## Validation Patterns + +### Pattern 1: Controller Input Validation + +**✅ DO: Use ZodValidationPipe** + +```typescript +import { Controller, Post, Body, UsePipes } from "@nestjs/common"; +import { ZodValidationPipe } from "@bff/core/validation"; +import { createOrderRequestSchema, type CreateOrderRequest } from "@customer-portal/domain/orders"; + +@Controller("orders") +export class OrdersController { + @Post() + @UsePipes(new ZodValidationPipe(createOrderRequestSchema)) + async create(@Body() body: CreateOrderRequest) { + return this.orderService.createOrder(body); + } +} +``` + +**❌ DON'T: Validate manually in controllers** + +```typescript +// Bad - manual validation in controller +@Post() +async create(@Body() body: any) { + if (!body.orderType || typeof body.orderType !== "string") { + throw new BadRequestException("Invalid order type"); + } + // ... +} +``` + +### Pattern 2: Schema Definition + +**✅ DO: Define schemas in domain layer** + +```typescript +// packages/domain/orders/schema.ts +import { z } from "zod"; + +export const createOrderRequestSchema = z.object({ + orderType: z.enum(["Internet", "SIM", "VPN"]), + skus: z.array(z.string().min(1)), + configurations: z.object({ + activationType: z.enum(["Immediate", "Scheduled"]).optional(), + }).optional(), +}); + +export type CreateOrderRequest = z.infer; +``` + +**❌ DON'T: Define validation logic in multiple places** + +```typescript +// Bad - validation in service duplicates schema +async createOrder(data: any) { + if (!data.orderType || !["Internet", "SIM", "VPN"].includes(data.orderType)) { + throw new Error("Invalid order type"); + } + // This duplicates the schema validation! +} +``` + +### Pattern 3: Business Validation Functions + +**✅ DO: Use helper functions with schema refinements** + +```typescript +// packages/domain/orders/validation.ts + +// Helper function (reusable) +export function hasSimServicePlan(skus: string[]): boolean { + return skus.some(sku => + sku.toUpperCase().includes("SIM") && + !sku.toUpperCase().includes("ACTIVATION") + ); +} + +// Schema uses helper function (DRY) +export const orderWithSkuValidationSchema = baseOrderSchema + .refine( + (data) => data.orderType !== "SIM" || hasSimServicePlan(data.skus), + { message: "SIM orders must include a service plan", path: ["skus"] } + ); +``` + +**❌ DON'T: Duplicate logic in refinements** + +```typescript +// Bad - logic duplicated between helper and schema +export function hasSimServicePlan(skus: string[]): boolean { + return skus.some(sku => sku.includes("SIM")); +} + +const schema = baseSchema.refine((data) => { + // Duplicates the helper function logic! + if (data.orderType === "SIM") { + return data.skus.some(sku => sku.includes("SIM")); + } + return true; +}); +``` + +### Pattern 4: Sanitization + +**✅ DO: Use schema transforms for sanitization** + +```typescript +// Good - sanitization in schema +export const emailSchema = z + .string() + .email() + .toLowerCase() + .trim(); +``` + +**✅ DO: Separate sanitization functions (if needed)** + +```typescript +// Good - pure sanitization (no validation) +export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest { + return { + userId: request.userId?.trim(), + whmcsClientId: request.whmcsClientId, + sfAccountId: request.sfAccountId?.trim() || undefined, + }; +} +``` + +**❌ DON'T: Mix sanitization with validation** + +```typescript +// Bad - mixing sanitization and validation +export function sanitizeAndValidate(request: any) { + const sanitized = { userId: request.userId?.trim() }; + + // Validation mixed with sanitization + if (!sanitized.userId || sanitized.userId.length === 0) { + throw new Error("Invalid userId"); + } + + return sanitized; +} +``` + +### Pattern 5: Validation Wrapper Functions + +**✅ DO: Keep wrappers simple and documented** + +```typescript +/** + * Validate and normalize email address + * + * This is a convenience wrapper that throws on invalid input. + * For validation without throwing, use emailSchema.safeParse() + * + * @throws Error if email format is invalid + */ +export function normalizeAndValidateEmail(email: string): string { + const emailValidationSchema = z.string().email().transform(e => e.toLowerCase().trim()); + return emailValidationSchema.parse(email); +} +``` + +**❌ DON'T: Use safeParse + manual error handling** + +```typescript +// Bad - unnecessary complexity +export function normalizeAndValidateEmail(email: string): string { + const result = emailSchema.safeParse(email); + if (!result.success) { + throw new Error("Invalid email"); + } + return result.data; +} + +// Better - just use .parse() +export function normalizeAndValidateEmail(email: string): string { + return emailSchema.parse(email); +} +``` + +### Pattern 6: Infrastructure Validation + +**✅ DO: Keep infrastructure checks in BFF services** + +```typescript +// Good - infrastructure validation in BFF +@Injectable() +export class OrderValidator { + async validateUserMapping(userId: string) { + const mapping = await this.mappings.findByUserId(userId); + if (!mapping) { + throw new BadRequestException("User mapping required"); + } + return mapping; + } +} +``` + +**✅ DO: Use schemas for type/format validation even in services** + +```typescript +// Good - use schema for format validation +const salesforceAccountIdSchema = z.string().min(1); + +async validateOrder(order: SalesforceOrder) { + const accountId = salesforceAccountIdSchema.parse(order.AccountId); + // Continue with business logic... +} +``` + +**❌ DON'T: Use manual type checks** + +```typescript +// Bad - manual type checking +if (typeof order.AccountId !== "string" || order.AccountId.length === 0) { + throw new Error("Invalid AccountId"); +} +``` + +## Common Anti-Patterns + +### Anti-Pattern 1: Duplicate Password Validation + +**❌ DON'T:** + +```typescript +// Schema defines password rules +const passwordSchema = z.string() + .min(8) + .regex(/[A-Z]/, "Must contain uppercase") + .regex(/[a-z]/, "Must contain lowercase"); + +// Service duplicates the same rules! +function validatePassword(password: string) { + if (password.length < 8 || !/[A-Z]/.test(password) || !/[a-z]/.test(password)) { + throw new Error("Invalid password"); + } +} +``` + +**✅ DO:** + +```typescript +// Schema defines rules once +const passwordSchema = z.string() + .min(8) + .regex(/[A-Z]/, "Must contain uppercase") + .regex(/[a-z]/, "Must contain lowercase"); + +// Service uses the schema +function validatePassword(password: string) { + return passwordSchema.parse(password); +} +``` + +### Anti-Pattern 2: Manual String Matching for Validation + +**❌ DON'T** (for input validation): + +```typescript +// Bad - manual string checks for validation +function validateOrderType(type: any) { + if (typeof type !== "string") return false; + if (!["Internet", "SIM", "VPN"].includes(type)) return false; + return true; +} +``` + +**✅ DO:** + +```typescript +// Good - use Zod enum +const orderTypeSchema = z.enum(["Internet", "SIM", "VPN"]); +``` + +**✅ OKAY** (for business logic with external data): + +```typescript +// This is fine - checking WHMCS API response data +const hasInternet = products.some(p => + p.groupname.toLowerCase().includes("internet") +); +``` + +### Anti-Pattern 3: Validation in Multiple Layers + +**❌ DON'T:** + +```typescript +// Controller validates +@Post() +async create(@Body() body: any) { + if (!body.email) throw new BadRequestException("Email required"); + + // Service validates again + await this.service.create(body); +} + +// Service +async create(data: any) { + if (!data.email || !data.email.includes("@")) { + throw new Error("Invalid email"); + } +} +``` + +**✅ DO:** + +```typescript +// Schema validates once +const createUserSchema = z.object({ + email: z.string().email(), +}); + +// Controller uses schema +@Post() +@UsePipes(new ZodValidationPipe(createUserSchema)) +async create(@Body() body: CreateUserRequest) { + return this.service.create(body); +} + +// Service trusts the validated input +async create(data: CreateUserRequest) { + // No validation needed - input is already validated + return this.repository.save(data); +} +``` + +## Migration Guide + +When refactoring existing validation code: + +1. **Identify Duplicate Logic**: Find manual validation that duplicates schemas +2. **Move to Domain Layer**: Define schemas in `packages/domain/*/schema.ts` +3. **Create Helper Functions**: Extract reusable business logic to `validation.ts` +4. **Update Services**: Replace manual checks with schema/function calls +5. **Update Controllers**: Add `@UsePipes(ZodValidationPipe)` decorators +6. **Remove Duplicate Code**: Delete manual validation that duplicates schemas +7. **Add Documentation**: Document why each validation exists + +## Testing Validation + +```typescript +import { describe, it, expect } from "vitest"; +import { orderTypeSchema } from "./schema"; + +describe("Order Type Validation", () => { + it("should accept valid order types", () => { + expect(orderTypeSchema.parse("Internet")).toBe("Internet"); + expect(orderTypeSchema.parse("SIM")).toBe("SIM"); + }); + + it("should reject invalid order types", () => { + expect(() => orderTypeSchema.parse("Invalid")).toThrow(); + expect(() => orderTypeSchema.parse(null)).toThrow(); + }); +}); +``` + +## Summary + +- **Use schemas for format validation** +- **Use functions for business validation** +- **Never duplicate validation logic** +- **Validate once at the entry point** +- **Trust validated data downstream** +- **Keep infrastructure validation separate** + +Following these patterns ensures maintainable, consistent validation across the entire codebase. + diff --git a/packages/domain/auth/contract.ts b/packages/domain/auth/contract.ts index 63e99641..a283f674 100644 --- a/packages/domain/auth/contract.ts +++ b/packages/domain/auth/contract.ts @@ -1,58 +1,51 @@ /** * Auth Domain - Contract - * - * Canonical authentication types shared across applications. - * Most types are derived from schemas (see schema.ts). + * + * Constants and types for the authentication domain. + * All validated types are derived from schemas (see schema.ts). */ -import type { IsoDateTimeString } from "../common/types"; -import type { CustomerProfile } from "../customer/contract"; - // ============================================================================ -// User Role +// Authentication Error Codes // ============================================================================ -export type UserRole = "USER" | "ADMIN"; +export const AUTH_ERROR_CODE = { + INVALID_CREDENTIALS: "INVALID_CREDENTIALS", + EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED", + ACCOUNT_LOCKED: "ACCOUNT_LOCKED", + MFA_REQUIRED: "MFA_REQUIRED", + INVALID_TOKEN: "INVALID_TOKEN", + TOKEN_EXPIRED: "TOKEN_EXPIRED", + PASSWORD_TOO_WEAK: "PASSWORD_TOO_WEAK", + EMAIL_ALREADY_EXISTS: "EMAIL_ALREADY_EXISTS", + WHMCS_ACCOUNT_NOT_FOUND: "WHMCS_ACCOUNT_NOT_FOUND", + SALESFORCE_ACCOUNT_NOT_FOUND: "SALESFORCE_ACCOUNT_NOT_FOUND", + LINKING_FAILED: "LINKING_FAILED", +} as const; + +export type AuthErrorCode = (typeof AUTH_ERROR_CODE)[keyof typeof AUTH_ERROR_CODE]; // ============================================================================ -// Authenticated User (Core Type) +// Token Type Constants // ============================================================================ -/** - * AuthenticatedUser - Complete user profile with authentication state - * Extends CustomerProfile (from WHMCS) with auth-specific fields from portal DB - * Follows WHMCS client field naming (firstname, lastname, etc.) - */ -export interface AuthenticatedUser extends CustomerProfile { - role: UserRole; - emailVerified: boolean; - mfaEnabled: boolean; - lastLoginAt?: IsoDateTimeString; -} +export const TOKEN_TYPE = { + BEARER: "Bearer", +} as const; -/** - * User profile type alias - */ -export type UserProfile = AuthenticatedUser; +export type TokenTypeValue = (typeof TOKEN_TYPE)[keyof typeof TOKEN_TYPE]; // ============================================================================ -// Auth Error (Business Type) +// Gender Constants // ============================================================================ -export interface AuthError { - code: - | "INVALID_CREDENTIALS" - | "USER_NOT_FOUND" - | "EMAIL_ALREADY_EXISTS" - | "EMAIL_NOT_VERIFIED" - | "INVALID_TOKEN" - | "TOKEN_EXPIRED" - | "ACCOUNT_LOCKED" - | "RATE_LIMITED" - | "NETWORK_ERROR"; - message: string; - details?: Record; -} +export const GENDER = { + MALE: "male", + FEMALE: "female", + OTHER: "other", +} as const; + +export type GenderValue = (typeof GENDER)[keyof typeof GENDER]; // ============================================================================ // Re-export Types from Schema (Schema-First Approach) @@ -73,10 +66,14 @@ export type { SsoLinkRequest, CheckPasswordNeededRequest, RefreshTokenRequest, - // Response types + // Token types AuthTokens, + // Response types AuthResponse, + SignupResult, + PasswordChangeResult, + SsoLinkResponse, + CheckPasswordNeededResponse, + // Error types + AuthError, } from './schema'; - -// Re-export from customer for convenience -export type { Activity } from "../dashboard/contract"; diff --git a/packages/domain/auth/index.ts b/packages/domain/auth/index.ts index cdab8815..7685330f 100644 --- a/packages/domain/auth/index.ts +++ b/packages/domain/auth/index.ts @@ -1,18 +1,27 @@ /** * Auth Domain * - * Exports all auth-related contracts, schemas, and provider mappers. + * Contains ONLY authentication mechanisms: + * - Login, Signup, Password Management + * - Token Management (JWT) + * - MFA, SSO * - * Types are derived from Zod schemas (Schema-First Approach) + * User entity types are in customer domain (@customer-portal/domain/customer) */ -// Business types -export { type UserRole, type AuthenticatedUser, type UserProfile, type AuthError } from "./contract"; +// ============================================================================ +// Constants & Contract Types +// ============================================================================ -// Schemas (includes derived types) -export * from "./schema"; +export { + AUTH_ERROR_CODE, + TOKEN_TYPE, + GENDER, + type AuthErrorCode, + type TokenTypeValue, + type GenderValue, +} from "./contract"; -// Re-export types for convenience export type { // Request types LoginRequest, @@ -28,9 +37,47 @@ export type { SsoLinkRequest, CheckPasswordNeededRequest, RefreshTokenRequest, - // Response types + // Token types AuthTokens, + // Response types AuthResponse, - // Re-exported - Activity, -} from './contract'; + SignupResult, + PasswordChangeResult, + SsoLinkResponse, + CheckPasswordNeededResponse, + // Error types + AuthError, +} from "./contract"; + +// ============================================================================ +// Schemas (for validation) +// ============================================================================ + +export { + // Request schemas + loginRequestSchema, + signupRequestSchema, + passwordResetRequestSchema, + passwordResetSchema, + setPasswordRequestSchema, + changePasswordRequestSchema, + linkWhmcsRequestSchema, + validateSignupRequestSchema, + updateCustomerProfileRequestSchema, + updateProfileRequestSchema, + updateAddressRequestSchema, + accountStatusRequestSchema, + ssoLinkRequestSchema, + checkPasswordNeededRequestSchema, + refreshTokenRequestSchema, + + // Token schemas + authTokensSchema, + + // Response schemas + authResponseSchema, + signupResultSchema, + passwordChangeResultSchema, + ssoLinkResponseSchema, + checkPasswordNeededResponseSchema, +} from "./schema"; diff --git a/packages/domain/auth/schema.ts b/packages/domain/auth/schema.ts index d18a75ae..c88d7aa6 100644 --- a/packages/domain/auth/schema.ts +++ b/packages/domain/auth/schema.ts @@ -1,11 +1,23 @@ /** - * Auth Domain - Schemas + * Auth Domain - Types + * + * Contains ONLY authentication mechanism types: + * - Login, Signup, Password Management + * - Token Management + * - MFA, SSO + * + * User entity types are in customer domain. + * Auth responses reference User from customer domain. */ import { z } from "zod"; import { emailSchema, nameSchema, passwordSchema, phoneSchema } from "../common/schema"; -import { addressSchema } from "../customer/schema"; +import { addressSchema, userSchema } from "../customer/schema"; + +// ============================================================================ +// Authentication Request Schemas +// ============================================================================ const genderEnum = z.enum(["male", "female", "other"]); @@ -108,6 +120,10 @@ export const refreshTokenRequestSchema = z.object({ deviceId: z.string().optional(), }); +// ============================================================================ +// Token Schemas +// ============================================================================ + export const authTokensSchema = z.object({ accessToken: z.string().min(1, "Access token is required"), refreshToken: z.string().min(1, "Refresh token is required"), @@ -116,13 +132,53 @@ export const authTokensSchema = z.object({ tokenType: z.literal("Bearer"), }); +// ============================================================================ +// Authentication Response Schemas (Reference User from Customer Domain) +// ============================================================================ + +/** + * Auth response - returns User from customer domain + */ export const authResponseSchema = z.object({ - user: z.unknown(), + user: userSchema, // User from customer domain tokens: authTokensSchema, }); +/** + * Signup result - returns User from customer domain + */ +export const signupResultSchema = z.object({ + user: userSchema, // User from customer domain + tokens: authTokensSchema, +}); + +/** + * Password change result - returns User from customer domain + */ +export const passwordChangeResultSchema = z.object({ + user: userSchema, // User from customer domain + tokens: authTokensSchema, +}); + +/** + * SSO link response + */ +export const ssoLinkResponseSchema = z.object({ + url: z.url(), + expiresAt: z.string(), +}); + +/** + * Check password needed response + */ +export const checkPasswordNeededResponseSchema = z.object({ + needsPasswordSet: z.boolean(), + userExists: z.boolean(), + email: z.email().optional(), +}); + // ============================================================================ -// Inferred Types from Schemas (Schema-First Approach) +// Inferred Types (Schema-First Approach) // ============================================================================ // Request types @@ -140,6 +196,23 @@ export type SsoLinkRequest = z.infer; export type CheckPasswordNeededRequest = z.infer; export type RefreshTokenRequest = z.infer; -// Response types +// Token types export type AuthTokens = z.infer; + +// Response types export type AuthResponse = z.infer; +export type SignupResult = z.infer; +export type PasswordChangeResult = z.infer; +export type SsoLinkResponse = z.infer; +export type CheckPasswordNeededResponse = z.infer; + +// ============================================================================ +// Error Types +// ============================================================================ + +export interface AuthError { + code: "INVALID_CREDENTIALS" | "EMAIL_NOT_VERIFIED" | "ACCOUNT_LOCKED" | "MFA_REQUIRED" | "INVALID_TOKEN" | "TOKEN_EXPIRED" | "PASSWORD_TOO_WEAK" | "EMAIL_ALREADY_EXISTS" | "WHMCS_ACCOUNT_NOT_FOUND" | "SALESFORCE_ACCOUNT_NOT_FOUND" | "LINKING_FAILED"; + message: string; + details?: unknown; +} + diff --git a/packages/domain/billing/index.ts b/packages/domain/billing/index.ts index 47c46902..b47bdf39 100644 --- a/packages/domain/billing/index.ts +++ b/packages/domain/billing/index.ts @@ -30,10 +30,14 @@ export type { // Provider adapters export * as Providers from "./providers"; -// Re-export provider raw types and response types -export * from "./providers/whmcs/raw.types"; - +// Re-export provider raw types (request and response) export type { + // Request params + WhmcsGetInvoicesParams, + WhmcsCreateInvoiceParams, + WhmcsUpdateInvoiceParams, + WhmcsCapturePaymentParams, + // Response types WhmcsInvoiceListResponse, WhmcsInvoiceResponse, WhmcsCreateInvoiceResponse, diff --git a/packages/domain/billing/providers/whmcs/raw.types.ts b/packages/domain/billing/providers/whmcs/raw.types.ts index 51f6a380..8d53804b 100644 --- a/packages/domain/billing/providers/whmcs/raw.types.ts +++ b/packages/domain/billing/providers/whmcs/raw.types.ts @@ -1,12 +1,96 @@ /** * WHMCS Billing Provider - Raw Types * - * Type definitions for raw WHMCS API responses related to billing. - * These types represent the actual structure returned by WHMCS APIs. + * Type definitions for the WHMCS billing API contract: + * - Request parameter types (API inputs) + * - Response types (API outputs) + * + * These represent the exact structure used by WHMCS APIs. */ import { z } from "zod"; +// ============================================================================ +// Request Parameter Types +// ============================================================================ + +/** + * Parameters for WHMCS GetInvoices API + */ +export interface WhmcsGetInvoicesParams { + userid?: number; // WHMCS API uses 'userid' not 'clientid' + status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; + limitstart?: number; + limitnum?: number; + orderby?: "id" | "invoicenum" | "date" | "duedate" | "total" | "status"; + order?: "ASC" | "DESC"; + [key: string]: unknown; +} + +/** + * Parameters for WHMCS CreateInvoice API + */ +export interface WhmcsCreateInvoiceParams { + userid: number; + status?: + | "Draft" + | "Paid" + | "Unpaid" + | "Cancelled" + | "Refunded" + | "Collections" + | "Overdue" + | "Payment Pending"; + sendnotification?: boolean; + paymentmethod?: string; + taxrate?: number; + taxrate2?: number; + date?: string; // YYYY-MM-DD format + duedate?: string; // YYYY-MM-DD format + notes?: string; + itemdescription1?: string; + itemamount1?: number; + itemtaxed1?: boolean; + itemdescription2?: string; + itemamount2?: number; + itemtaxed2?: boolean; + // Can have up to 24 line items (itemdescription1-24, itemamount1-24, itemtaxed1-24) + [key: string]: unknown; +} + +/** + * Parameters for WHMCS UpdateInvoice API + */ +export interface WhmcsUpdateInvoiceParams { + invoiceid: number; + status?: "Draft" | "Paid" | "Unpaid" | "Cancelled" | "Refunded" | "Collections" | "Overdue"; + duedate?: string; // YYYY-MM-DD format + notes?: string; + [key: string]: unknown; +} + +/** + * Parameters for WHMCS CapturePayment API + */ +export interface WhmcsCapturePaymentParams { + invoiceid: number; + cvv?: string; + cardnum?: string; + cccvv?: string; + cardtype?: string; + cardexp?: string; + // For existing payment methods + paymentmethodid?: number; + // Manual payment capture + transid?: string; + gateway?: string; + [key: string]: unknown; +} + +// ============================================================================ +// Response Types +// ============================================================================ + // Raw WHMCS Invoice Item export const whmcsInvoiceItemRawSchema = z.object({ id: z.number(), diff --git a/packages/domain/common/index.ts b/packages/domain/common/index.ts index 20b57dbf..e7be5bf3 100644 --- a/packages/domain/common/index.ts +++ b/packages/domain/common/index.ts @@ -6,6 +6,7 @@ export * from "./types"; export * from "./schema"; +export * from "./validation"; // Common provider types (generic wrappers used across domains) export * as CommonProviders from "./providers"; diff --git a/packages/domain/common/validation.ts b/packages/domain/common/validation.ts new file mode 100644 index 00000000..10732269 --- /dev/null +++ b/packages/domain/common/validation.ts @@ -0,0 +1,98 @@ +/** + * Common Domain - Validation Utilities + * + * Generic validation functions used across all domains. + * These are pure functions with no infrastructure dependencies. + */ + +import { z } from "zod"; + +/** + * UUID validation schema (v4) + */ +export const uuidSchema = z.string().uuid(); + +/** + * Normalize and validate an email address + * + * This is a convenience wrapper that throws on invalid input. + * For validation without throwing, use the emailSchema directly with .safeParse() + * + * @throws Error if email format is invalid + */ +export function normalizeAndValidateEmail(email: string): string { + const emailValidationSchema = z.string().email().transform(e => e.toLowerCase().trim()); + return emailValidationSchema.parse(email); +} + +/** + * Validate a UUID (v4) + * + * This is a convenience wrapper that throws on invalid input. + * For validation without throwing, use the uuidSchema directly with .safeParse() + * + * @throws Error if UUID format is invalid + */ +export function validateUuidV4OrThrow(id: string): string { + try { + return uuidSchema.parse(id); + } catch { + throw new Error("Invalid user ID format"); + } +} + +/** + * Check if a string is a valid email (non-throwing) + */ +export function isValidEmail(email: string): boolean { + return z.string().email().safeParse(email).success; +} + +/** + * Check if a string is a valid UUID (non-throwing) + */ +export function isValidUuid(id: string): boolean { + return uuidSchema.safeParse(id).success; +} + +/** + * URL validation schema + */ +export const urlSchema = z.string().url(); + +/** + * Validate a URL + * + * This is a convenience wrapper that throws on invalid input. + * For validation without throwing, use the urlSchema directly with .safeParse() + * + * @throws Error if URL format is invalid + */ +export function validateUrlOrThrow(url: string): string { + try { + return urlSchema.parse(url); + } catch { + throw new Error("Invalid URL format"); + } +} + +/** + * Validate a URL (non-throwing) + * + * Returns validation result with errors if any. + * Prefer using urlSchema.safeParse() directly for more control. + */ +export function validateUrl(url: string): { isValid: boolean; errors: string[] } { + const result = urlSchema.safeParse(url); + return { + isValid: result.success, + errors: result.success ? [] : result.error.issues.map(i => i.message), + }; +} + +/** + * Check if a string is a valid URL (non-throwing) + */ +export function isValidUrl(url: string): boolean { + return urlSchema.safeParse(url).success; +} diff --git a/packages/domain/customer/contract.ts b/packages/domain/customer/contract.ts index 89e772cb..2fd7f7e1 100644 --- a/packages/domain/customer/contract.ts +++ b/packages/domain/customer/contract.ts @@ -1,45 +1,40 @@ /** * Customer Domain - Contract * - * Business types and provider-specific mapping types. - * Validated types are derived from schemas (see schema.ts). + * Constants and provider-specific types. + * Main domain types exported from schema.ts + * + * Pattern matches billing and subscriptions domains. */ -import type { IsoDateTimeString } from "../common/types"; -import type { CustomerAddress } from "./schema"; - // ============================================================================ -// Customer Profile (Core Type) +// User Role Constants // ============================================================================ -/** - * CustomerProfile - Core profile data following WHMCS client structure - * Used as the base for authenticated users in the portal - */ -export interface CustomerProfile { - id: string; - email: string; - firstname?: string | null; - lastname?: string | null; - fullname?: string | null; - companyname?: string | null; - phonenumber?: string | null; - address?: CustomerAddress; - language?: string | null; - currencyCode?: string | null; - createdAt?: IsoDateTimeString | null; - updatedAt?: IsoDateTimeString | null; -} +export const USER_ROLE = { + USER: "USER", + ADMIN: "ADMIN", +} as const; + +export type UserRoleValue = (typeof USER_ROLE)[keyof typeof USER_ROLE]; // ============================================================================ // Salesforce Integration Types (Provider-Specific, Not Validated) // ============================================================================ +/** + * Salesforce account field mapping + * This is provider-specific and not validated at runtime + */ export interface SalesforceAccountFieldMap { internetEligibility: string; customerNumber: string; } +/** + * Salesforce account record structure + * Raw structure from Salesforce API + */ export interface SalesforceAccountRecord { Id: string; Name?: string | null; @@ -52,14 +47,9 @@ export interface SalesforceAccountRecord { // ============================================================================ export type { - CustomerAddress, + User, + UserAuth, + UserRole, Address, - CustomerEmailPreferences, - CustomerUser, - CustomerStats, - Customer, AddressFormData, } from './schema'; - -// Re-export helper function -export { addressFormToRequest } from './schema'; diff --git a/packages/domain/customer/index.ts b/packages/domain/customer/index.ts index 361d6093..3c46991e 100644 --- a/packages/domain/customer/index.ts +++ b/packages/domain/customer/index.ts @@ -1,43 +1,92 @@ /** * Customer Domain * - * Exports all customer-related contracts, schemas, and provider mappers. + * Main exports: + * - User: API response type + * - UserAuth: Portal DB auth state + * - Address: Address structure (follows billing/subscriptions pattern) + * + * Pattern matches billing and subscriptions domains. * * Types are derived from Zod schemas (Schema-First Approach) */ -// Business types -export { type CustomerProfile } from "./contract"; +// ============================================================================ +// Constants +// ============================================================================ + +export { USER_ROLE, type UserRoleValue } from "./contract"; + +// ============================================================================ +// Domain Types (Clean Names - Public API) +// ============================================================================ -// Provider-specific types export type { - SalesforceAccountFieldMap, - SalesforceAccountRecord, -} from "./contract"; + User, // API response type (normalized camelCase) + UserAuth, // Portal DB auth state + UserRole, // "USER" | "ADMIN" + Address, // Address structure (not "CustomerAddress") + AddressFormData, // Address form validation +} from "./schema"; -// Schemas (includes derived types) -export * from "./schema"; +// ============================================================================ +// Schemas +// ============================================================================ -// Re-export types for convenience -export type { - CustomerAddress, - Address, - CustomerEmailPreferences, - CustomerUser, - CustomerStats, - Customer, - AddressFormData, -} from './schema'; +export { + userSchema, + userAuthSchema, + addressSchema, + addressFormSchema, + + // Helper functions + combineToUser, // Domain helper: UserAuth + WhmcsClient → User + addressFormToRequest, +} from "./schema"; -// Re-export helper function -export { addressFormToRequest } from './schema'; +// ============================================================================ +// Provider Namespace +// ============================================================================ -// Provider adapters +/** + * Providers namespace contains provider-specific implementations + * + * Access as: + * - Providers.Whmcs.Client (full WHMCS type) + * - Providers.Whmcs.transformWhmcsClientResponse() + * - Providers.Portal.mapPrismaUserToUserAuth() + */ export * as Providers from "./providers"; -// Re-export provider response types +// ============================================================================ +// Provider Raw Response Types (Selective Exports) +// ============================================================================ + +/** + * WHMCS API raw types (request and response) + * Only exported for BFF integration convenience + */ export type { + // Request params + WhmcsAddClientParams, + WhmcsValidateLoginParams, + WhmcsCreateSsoTokenParams, + // Response types + WhmcsClientResponse, WhmcsAddClientResponse, WhmcsValidateLoginResponse, WhmcsSsoResponse, } from "./providers/whmcs/raw.types"; + +// ============================================================================ +// Provider-Specific Types (For Integrations) +// ============================================================================ + +/** + * Salesforce integration types + * Provider-specific, not validated at runtime + */ +export type { + SalesforceAccountFieldMap, + SalesforceAccountRecord, +} from "./contract"; diff --git a/packages/domain/customer/providers/index.ts b/packages/domain/customer/providers/index.ts index 2e1c1f1f..33007507 100644 --- a/packages/domain/customer/providers/index.ts +++ b/packages/domain/customer/providers/index.ts @@ -1,10 +1,10 @@ /** * Customer Domain - Providers + * + * Providers handle mapping from external systems to domain types: + * - Portal: Prisma (portal DB) → UserAuth + * - Whmcs: WHMCS API → WhmcsClient */ -import * as WhmcsModule from "./whmcs"; - -export const Whmcs = WhmcsModule; - -export { WhmcsModule }; -export * from "./whmcs"; +export * as Portal from "./portal"; +export * as Whmcs from "./whmcs"; diff --git a/packages/domain/customer/providers/portal/index.ts b/packages/domain/customer/providers/portal/index.ts new file mode 100644 index 00000000..ab0d3e8c --- /dev/null +++ b/packages/domain/customer/providers/portal/index.ts @@ -0,0 +1,9 @@ +/** + * Portal Provider + * + * Handles mapping from Prisma (portal database) to UserAuth domain type + */ + +export * from "./mapper"; +export * from "./types"; + diff --git a/packages/domain/customer/providers/portal/mapper.ts b/packages/domain/customer/providers/portal/mapper.ts new file mode 100644 index 00000000..d7df7193 --- /dev/null +++ b/packages/domain/customer/providers/portal/mapper.ts @@ -0,0 +1,31 @@ +/** + * Portal Provider - Mapper + * + * Maps Prisma user data to UserAuth domain type using schema validation + */ + +import { userAuthSchema } from "../../schema"; +import type { PrismaUserRaw } from "./types"; +import type { UserAuth } from "../../schema"; + +/** + * Maps raw Prisma user data to UserAuth domain type + * + * Uses schema validation for runtime type safety + * + * @param raw - Raw Prisma user data from portal database + * @returns Validated UserAuth with only authentication state + */ +export function mapPrismaUserToUserAuth(raw: PrismaUserRaw): UserAuth { + return userAuthSchema.parse({ + id: raw.id, + email: raw.email, + role: raw.role, + mfaEnabled: !!raw.mfaSecret, + emailVerified: raw.emailVerified, + lastLoginAt: raw.lastLoginAt?.toISOString(), + createdAt: raw.createdAt.toISOString(), + updatedAt: raw.updatedAt.toISOString(), + }); +} + diff --git a/packages/domain/customer/providers/portal/types.ts b/packages/domain/customer/providers/portal/types.ts new file mode 100644 index 00000000..e9266b0e --- /dev/null +++ b/packages/domain/customer/providers/portal/types.ts @@ -0,0 +1,27 @@ +/** + * Portal Provider - Raw Types + * + * Raw Prisma user data interface. + * Domain doesn't depend on @prisma/client directly. + */ + +import type { UserRole } from "../../schema"; + +/** + * Raw Prisma user data from portal database + * This interface matches the Prisma User schema but doesn't import from @prisma/client + */ +export interface PrismaUserRaw { + id: string; + email: string; + passwordHash: string | null; + role: UserRole; + mfaSecret: string | null; + emailVerified: boolean; + failedLoginAttempts: number; + lockedUntil: Date | null; + lastLoginAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + diff --git a/packages/domain/customer/providers/whmcs/index.ts b/packages/domain/customer/providers/whmcs/index.ts index d3266bb9..232b5f5c 100644 --- a/packages/domain/customer/providers/whmcs/index.ts +++ b/packages/domain/customer/providers/whmcs/index.ts @@ -1,17 +1,12 @@ -import * as WhmcsMapper from "./mapper"; -import * as WhmcsRaw from "./raw.types"; +/** + * WHMCS Provider + * + * Handles mapping from WHMCS API to domain types. + * Exports transformation functions and raw API types (request/response). + */ -export const Whmcs = { - ...WhmcsMapper, - mapper: WhmcsMapper, - raw: WhmcsRaw, - schemas: { - response: WhmcsRaw.whmcsClientResponseSchema, - client: WhmcsRaw.whmcsClientSchema, - stats: WhmcsRaw.whmcsClientStatsSchema, - }, -}; - -export { WhmcsMapper, WhmcsRaw }; export * from "./mapper"; export * from "./raw.types"; + +// Re-export domain types for provider namespace convenience +export type { WhmcsClient, EmailPreferences, SubUser, Stats } from "../../schema"; diff --git a/packages/domain/customer/providers/whmcs/mapper.ts b/packages/domain/customer/providers/whmcs/mapper.ts index 7cb1b080..8e7f17a3 100644 --- a/packages/domain/customer/providers/whmcs/mapper.ts +++ b/packages/domain/customer/providers/whmcs/mapper.ts @@ -1,112 +1,55 @@ +/** + * WHMCS Provider - Mapper + * + * Maps WHMCS API responses to domain types. + * Minimal transformation - validates and normalizes only address structure. + */ + import { z } from "zod"; -import type { Customer, CustomerAddress, CustomerStats } from "../../contract"; +import type { WhmcsClient, Address } from "../../schema"; +import { whmcsClientSchema, addressSchema } from "../../schema"; import { - customerAddressSchema, - customerEmailPreferencesSchema, - customerSchema, - customerStatsSchema, - customerUserSchema, -} from "../../schema"; -import { - whmcsClientSchema, + whmcsClientSchema as whmcsRawClientSchema, whmcsClientResponseSchema, - whmcsClientStatsSchema, - type WhmcsClient, + type WhmcsClient as WhmcsRawClient, type WhmcsClientResponse, - type WhmcsClientStats, - whmcsCustomFieldSchema, - whmcsUserSchema, } from "./raw.types"; -const toBoolean = (value: unknown): boolean | undefined => { - if (value === undefined || value === null) { - return undefined; - } - 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 undefined; -}; +/** + * Parse and validate WHMCS client response + */ +export const parseWhmcsClientResponse = (raw: unknown): WhmcsClientResponse => + whmcsClientResponseSchema.parse(raw); -const toNumber = (value: unknown): number | undefined => { - if (value === undefined || value === null) { - return undefined; - } - if (typeof value === "number") { - return value; - } - const parsed = Number.parseInt(String(value).replace(/[^0-9-]/g, ""), 10); - return Number.isFinite(parsed) ? parsed : undefined; -}; +/** + * Transform WHMCS client response to domain WhmcsClient + */ +export function transformWhmcsClientResponse(response: unknown): WhmcsClient { + const parsed = parseWhmcsClientResponse(response); + return transformWhmcsClient(parsed.client); +} -const toString = (value: unknown): string | undefined => { - if (value === undefined || value === null) { - return undefined; - } - return String(value); -}; +/** + * Transform raw WHMCS client to domain WhmcsClient + * + * Keeps raw WHMCS field names, only normalizes: + * - Address structure to domain Address type + * - Type coercions (strings to numbers/booleans) + */ +export function transformWhmcsClient(raw: WhmcsRawClient): WhmcsClient { + return whmcsClientSchema.parse({ + ...raw, + // Only normalize address to our domain structure + address: normalizeAddress(raw), + }); +} -const normalizeCustomFields = (input: unknown): Record | undefined => { - if (!input) { - return undefined; - } - - const parsed = whmcsCustomFieldSchema.array().safeParse(input).success - ? (input as Array<{ id: number | string; value?: string | null; name?: string }>) - : (() => { - const wrapped = z.object({ customfield: z.union([whmcsCustomFieldSchema, whmcsCustomFieldSchema.array()]) }).safeParse(input); - if (wrapped.success) { - const value = wrapped.data.customfield; - return Array.isArray(value) ? value : [value]; - } - return undefined; - })(); - - if (!parsed) { - return undefined; - } - - const result: Record = {}; - for (const field of parsed) { - const key = field.name || String(field.id); - if (key) { - result[key] = field.value ?? ""; - } - } - return Object.keys(result).length > 0 ? result : undefined; -}; - -const normalizeUsers = (input: unknown): Customer["users"] => { - if (!input) { - return undefined; - } - - const parsed = z - .union([whmcsUserSchema, whmcsUserSchema.array()]) - .safeParse(input); - - if (!parsed.success) { - return undefined; - } - - const usersArray = Array.isArray(parsed.data) ? parsed.data : [parsed.data]; - - const normalize = (value: z.infer) => customerUserSchema.parse(value); - - const users = usersArray.map(normalize); - return users.length > 0 ? users : undefined; -}; - -const normalizeAddress = (client: WhmcsClient): CustomerAddress | undefined => { - const address = customerAddressSchema.parse({ +/** + * Normalize WHMCS address fields to domain Address structure + */ +function normalizeAddress(client: WhmcsRawClient): Address | undefined { + const address = addressSchema.parse({ address1: client.address1 ?? null, address2: client.address2 ?? null, city: client.city ?? null, @@ -114,90 +57,28 @@ const normalizeAddress = (client: WhmcsClient): CustomerAddress | undefined => { postcode: client.postcode ?? null, country: client.country ?? null, countryCode: client.countrycode ?? null, - phoneNumber: client.phonenumberformatted ?? client.telephoneNumber ?? client.phonenumber ?? null, + phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? null, phoneCountryCode: client.phonecc ?? null, }); - const hasValues = Object.values(address).some(value => value !== undefined && value !== null && value !== ""); + const hasValues = Object.values(address).some(v => v !== undefined && v !== null && v !== ""); return hasValues ? address : undefined; -}; +} -const normalizeEmailPreferences = (input: unknown) => customerEmailPreferencesSchema.parse(input ?? {}); - -const normalizeStats = (input: unknown): CustomerStats | undefined => { - const parsed = whmcsClientStatsSchema.safeParse(input); - if (!parsed.success || !parsed.data) { - return undefined; - } - const stats = customerStatsSchema.parse(parsed.data); - return stats; -}; - -export const parseWhmcsClient = (raw: unknown): WhmcsClient => whmcsClientSchema.parse(raw); - -export const parseWhmcsClientResponse = (raw: unknown): WhmcsClientResponse => - whmcsClientResponseSchema.parse(raw); - -export const transformWhmcsClient = (raw: unknown): Customer => { - const client = parseWhmcsClient(raw); - const customer: Customer = { - id: Number(client.id), - clientId: client.client_id ? Number(client.client_id) : client.userid ? Number(client.userid) : undefined, - ownerUserId: client.owner_user_id ? Number(client.owner_user_id) : undefined, - userId: client.userid ? Number(client.userid) : undefined, - uuid: client.uuid ?? null, - firstname: client.firstname ?? null, - lastname: client.lastname ?? null, - fullname: client.fullname ?? null, - companyName: client.companyname ?? null, - email: client.email, - status: client.status ?? null, - language: client.language ?? null, - defaultGateway: client.defaultgateway ?? null, - defaultPaymentMethodId: client.defaultpaymethodid ? Number(client.defaultpaymethodid) : undefined, - currencyId: client.currency !== undefined ? toNumber(client.currency) : undefined, - currencyCode: client.currency_code ?? null, - taxId: client.tax_id ?? null, - phoneNumber: client.phonenumberformatted ?? client.telephoneNumber ?? client.phonenumber ?? null, - phoneCountryCode: client.phonecc ?? null, - telephoneNumber: client.telephoneNumber ?? null, - allowSingleSignOn: toBoolean(client.allowSingleSignOn) ?? null, - emailVerified: toBoolean(client.email_verified) ?? null, - marketingEmailsOptIn: - toBoolean(client.isOptedInToMarketingEmails) ?? toBoolean(client.marketing_emails_opt_in) ?? null, - notes: client.notes ?? null, - createdAt: client.datecreated ?? null, - lastLogin: client.lastlogin ?? null, - address: normalizeAddress(client), - emailPreferences: normalizeEmailPreferences(client.email_preferences), - customFields: normalizeCustomFields(client.customfields), - users: normalizeUsers(client.users?.user ?? client.users), - raw: client, - }; - - return customerSchema.parse(customer); -}; - -export const transformWhmcsClientResponse = (raw: unknown): Customer => { - const parsed = parseWhmcsClientResponse(raw); - const customer = transformWhmcsClient(parsed.client); - const stats = normalizeStats(parsed.stats); - if (stats) { - customer.stats = stats; - } - customer.raw = parsed; - return customerSchema.parse(customer); -}; - -export const transformWhmcsClientAddress = (raw: unknown): CustomerAddress | undefined => { - const client = parseWhmcsClient(raw); +/** + * Transform WHMCS client address to domain Address + */ +export const transformWhmcsClientAddress = (raw: unknown): Address | undefined => { + const client = whmcsRawClientSchema.parse(raw); return normalizeAddress(client); }; -export const transformWhmcsClientStats = (raw: unknown): CustomerStats | undefined => normalizeStats(raw); - +/** + * Prepare address update for WHMCS API + * Converts domain Address to WHMCS field names + */ export const prepareWhmcsClientAddressUpdate = ( - address: Partial + address: Partial
): Record => { const update: Record = {}; if (address.address1 !== undefined) update.address1 = address.address1 ?? ""; diff --git a/packages/domain/customer/providers/whmcs/raw.types.ts b/packages/domain/customer/providers/whmcs/raw.types.ts index 355a6ccd..55bd52ae 100644 --- a/packages/domain/customer/providers/whmcs/raw.types.ts +++ b/packages/domain/customer/providers/whmcs/raw.types.ts @@ -1,5 +1,58 @@ import { z } from "zod"; +// ============================================================================ +// Request Parameter Types +// ============================================================================ + +/** + * Parameters for WHMCS ValidateLogin API + */ +export interface WhmcsValidateLoginParams { + email: string; + password2: string; + [key: string]: unknown; +} + +/** + * Parameters for WHMCS AddClient API + */ +export interface WhmcsAddClientParams { + firstname: string; + lastname: string; + email: string; + address1?: string; + city?: string; + state?: string; + postcode?: string; + country?: string; + phonenumber?: string; + password2: string; + companyname?: string; + currency?: string; + groupid?: number; + customfields?: Record; + language?: string; + clientip?: string; + notes?: string; + marketing_emails_opt_in?: boolean; + no_email?: boolean; + [key: string]: unknown; +} + +/** + * Parameters for WHMCS CreateSsoToken API + */ +export interface WhmcsCreateSsoTokenParams { + client_id: number; + destination?: string; + sso_redirect_path?: string; + [key: string]: unknown; +} + +// ============================================================================ +// Response Types +// ============================================================================ + const booleanLike = z.union([z.boolean(), z.number(), z.string()]); const numberLike = z.union([z.number(), z.string()]); diff --git a/packages/domain/customer/schema.ts b/packages/domain/customer/schema.ts index 05fbba54..7ecfd574 100644 --- a/packages/domain/customer/schema.ts +++ b/packages/domain/customer/schema.ts @@ -1,12 +1,50 @@ +/** + * 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"; +// ============================================================================ +// 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()]); -export const customerAddressSchema = z.object({ +/** + * 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(), @@ -18,147 +56,6 @@ export const customerAddressSchema = z.object({ phoneCountryCode: stringOrNull.optional(), }); -export const customerEmailPreferencesSchema = z - .object({ - general: booleanLike.optional(), - invoice: booleanLike.optional(), - support: booleanLike.optional(), - product: booleanLike.optional(), - domain: booleanLike.optional(), - affiliate: booleanLike.optional(), - }) - .transform(prefs => { - const normalizeBoolean = (input: unknown): boolean | undefined => { - if (input === undefined || input === null) return undefined; - if (typeof input === "boolean") return input; - if (typeof input === "number") return input === 1; - if (typeof input === "string") { - const normalized = input.trim().toLowerCase(); - return normalized === "1" || normalized === "true" || normalized === "yes"; - } - return undefined; - }; - - return { - general: normalizeBoolean(prefs.general), - invoice: normalizeBoolean(prefs.invoice), - support: normalizeBoolean(prefs.support), - product: normalizeBoolean(prefs.product), - domain: normalizeBoolean(prefs.domain), - affiliate: normalizeBoolean(prefs.affiliate), - }; - }); - -export const customerUserSchema = 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, - isOwner: (() => { - const value = user.is_owner; - if (value === undefined || value === null) return false; - 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"; - } - return false; - })(), - })); - -const statsRecord = z - .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) - .optional(); - -export const customerStatsSchema = statsRecord.transform(stats => { - if (!stats) return undefined; - - const toNumber = (input: unknown): number | undefined => { - if (input === undefined || input === null) return undefined; - if (typeof input === "number") return input; - const parsed = Number.parseInt(String(input).replace(/[^0-9-]/g, ""), 10); - return Number.isFinite(parsed) ? parsed : undefined; - }; - - const toString = (input: unknown): string | undefined => { - if (input === undefined || input === null) return undefined; - return String(input); - }; - - const toBool = (input: unknown): boolean | undefined => { - if (input === undefined || input === null) return undefined; - if (typeof input === "boolean") return input; - if (typeof input === "number") return input === 1; - if (typeof input === "string") { - const normalized = input.trim().toLowerCase(); - return normalized === "1" || normalized === "true" || normalized === "yes"; - } - return undefined; - }; - - const normalized: Record = { - numDueInvoices: toNumber(stats.numdueinvoices), - dueInvoicesBalance: toString(stats.dueinvoicesbalance), - numOverdueInvoices: toNumber(stats.numoverdueinvoices), - overdueInvoicesBalance: toString(stats.overdueinvoicesbalance), - numUnpaidInvoices: toNumber(stats.numunpaidinvoices), - unpaidInvoicesAmount: toString(stats.unpaidinvoicesamount), - numPaidInvoices: toNumber(stats.numpaidinvoices), - paidInvoicesAmount: toString(stats.paidinvoicesamount), - creditBalance: toString(stats.creditbalance), - inCredit: toBool(stats.incredit), - isAffiliate: toBool(stats.isAffiliate), - productsNumActive: toNumber(stats.productsnumactive), - productsNumTotal: toNumber(stats.productsnumtotal), - activeDomains: toNumber(stats.numactivedomains), - raw: stats, - }; - - return normalized; -}); - -export const customerSchema = z.object({ - id: z.number().int().positive(), - clientId: z.number().int().optional(), - ownerUserId: z.number().int().nullable().optional(), - userId: z.number().int().nullable().optional(), - uuid: z.string().nullable().optional(), - firstname: z.string().nullable().optional(), - lastname: z.string().nullable().optional(), - fullname: z.string().nullable().optional(), - companyName: z.string().nullable().optional(), - email: z.string(), - status: z.string().nullable().optional(), - language: z.string().nullable().optional(), - defaultGateway: z.string().nullable().optional(), - defaultPaymentMethodId: z.number().int().nullable().optional(), - currencyId: z.number().int().nullable().optional(), - currencyCode: z.string().nullable().optional(), - taxId: z.string().nullable().optional(), - phoneNumber: z.string().nullable().optional(), - phoneCountryCode: z.string().nullable().optional(), - telephoneNumber: z.string().nullable().optional(), - allowSingleSignOn: z.boolean().nullable().optional(), - emailVerified: z.boolean().nullable().optional(), - marketingEmailsOptIn: z.boolean().nullable().optional(), - notes: z.string().nullable().optional(), - createdAt: z.string().nullable().optional(), - lastLogin: z.string().nullable().optional(), - address: customerAddressSchema.nullable().optional(), - emailPreferences: customerEmailPreferencesSchema.nullable().optional(), - customFields: z.record(z.string(), z.string()).optional(), - users: z.array(customerUserSchema).optional(), - stats: customerStatsSchema.optional(), - raw: z.record(z.string(), z.unknown()).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(), @@ -171,17 +68,183 @@ export const addressFormSchema = z.object({ phoneCountryCode: z.string().optional(), }); -// Duplicate identifier - remove this -// export type AddressFormData = z.infer; +// ============================================================================ +// UserAuth Schema (Portal Database - Auth State Only) +// ============================================================================ -const emptyToNull = (value?: string | null) => { - if (value === undefined) return undefined; - const trimmed = value?.trim(); - return trimmed ? trimmed : null; -}; +/** + * 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(), +}); -export const addressFormToRequest = (form: AddressFormData): CustomerAddress => - customerAddressSchema.parse({ +// ============================================================================ +// 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(); + +/** + * 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) + */ +export const whmcsClientSchema = z.object({ + id: numberLike, + email: z.string(), + + // Profile (raw WHMCS field names) + 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(), + phonenumberformatted: z.string().nullable().optional(), + telephoneNumber: z.string().nullable().optional(), + + // Billing & Payment (raw WHMCS field names) + status: z.string().nullable().optional(), + language: z.string().nullable().optional(), + defaultgateway: z.string().nullable().optional(), + defaultpaymethodid: numberLike.nullable().optional(), + currency: numberLike.nullable().optional(), + currency_code: z.string().nullable().optional(), // snake_case from WHMCS + tax_id: z.string().nullable().optional(), + + // Preferences (raw WHMCS field names) + allowSingleSignOn: booleanLike.nullable().optional(), + email_verified: booleanLike.nullable().optional(), // snake_case from WHMCS + marketing_emails_opt_in: booleanLike.nullable().optional(), // snake_case from WHMCS + + // Metadata (raw WHMCS field names) + notes: z.string().nullable().optional(), + datecreated: z.string().nullable().optional(), + lastlogin: z.string().nullable().optional(), + + // Relations + address: addressSchema.nullable().optional(), + email_preferences: emailPreferencesSchema.nullable().optional(), // snake_case from WHMCS + customfields: z.record(z.string(), z.string()).optional(), + users: z.array(subUserSchema).optional(), + stats: statsSchema.optional(), +}).transform(data => ({ + ...data, + // Normalize types only, keep field names as-is + id: typeof data.id === 'number' ? data.id : Number(data.id), + allowSingleSignOn: normalizeBoolean(data.allowSingleSignOn), + email_verified: normalizeBoolean(data.email_verified), + marketing_emails_opt_in: normalizeBoolean(data.marketing_emails_opt_in), + defaultpaymethodid: data.defaultpaymethodid ? Number(data.defaultpaymethodid) : null, + currency: data.currency ? Number(data.currency) : null, +})); + +// ============================================================================ +// User Schema (API Response - Normalized camelCase) +// ============================================================================ + +/** + * User - Complete user profile for API responses + * + * Composition: UserAuth (portal DB) + WhmcsClient (WHMCS) + * Field names normalized to camelCase for API consistency + * + * Use combineToUser() helper to construct from sources + */ +export const userSchema = userAuthSchema.extend({ + // Profile fields (normalized from WHMCS) + 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(), + currencyCode: z.string().nullable().optional(), // Normalized from currency_code + address: addressSchema.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), @@ -192,24 +255,60 @@ export const addressFormToRequest = (form: AddressFormData): CustomerAddress => phoneNumber: emptyToNull(form.phoneNumber ?? null), phoneCountryCode: emptyToNull(form.phoneCountryCode ?? null), }); +} -export type CustomerAddressSchema = typeof customerAddressSchema; -export type CustomerSchema = typeof customerSchema; -export type CustomerStatsSchema = typeof customerStatsSchema; - -export const addressSchema = customerAddressSchema; -export type AddressSchema = typeof addressSchema; +/** + * Combine UserAuth and WhmcsClient into User + * + * This is the single source of truth for constructing User from its sources. + * Maps raw WHMCS field names to normalized User field names. + * + * @param userAuth - Authentication state from portal database + * @param whmcsClient - Full client data from WHMCS + * @returns User object for API responses + */ +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 (map raw names to normalized) + 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, + currencyCode: whmcsClient.currency_code || null, // Normalize snake_case + address: whmcsClient.address || undefined, + }); +} // ============================================================================ -// Inferred Types from Schemas (Schema-First Approach) +// Exported Types (Public API) // ============================================================================ -export type CustomerAddress = z.infer; -export type CustomerEmailPreferences = z.infer; -export type CustomerUser = z.infer; -export type CustomerStats = z.infer; -export type Customer = z.infer; +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; -// Type aliases -export type Address = CustomerAddress; +// ============================================================================ +// 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 }; diff --git a/packages/domain/mappings/contract.ts b/packages/domain/mappings/contract.ts index 4f869103..9bf0b6f1 100644 --- a/packages/domain/mappings/contract.ts +++ b/packages/domain/mappings/contract.ts @@ -25,3 +25,12 @@ export interface UpdateMappingRequest { whmcsClientId?: number; sfAccountId?: string; } + +/** + * Validation result for mapping operations + */ +export interface MappingValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} diff --git a/packages/domain/mappings/index.ts b/packages/domain/mappings/index.ts index 4601b92c..3a6a9624 100644 --- a/packages/domain/mappings/index.ts +++ b/packages/domain/mappings/index.ts @@ -4,3 +4,13 @@ export * from "./contract"; export * from "./schema"; +export * from "./validation"; + +// Re-export types for convenience +export type { + MappingSearchFilters, + MappingStats, + BulkMappingOperation, + BulkMappingResult, +} from "./schema"; +export type { MappingValidationResult } from "./contract"; diff --git a/packages/domain/mappings/schema.ts b/packages/domain/mappings/schema.ts index 6f77c429..47650c0f 100644 --- a/packages/domain/mappings/schema.ts +++ b/packages/domain/mappings/schema.ts @@ -34,3 +34,61 @@ export const userIdMappingSchema: z.ZodType = z.object({ createdAt: z.union([z.string(), z.date()]), updatedAt: z.union([z.string(), z.date()]), }); + +// ============================================================================ +// Query and Filter Schemas +// ============================================================================ + +export const mappingSearchFiltersSchema = z.object({ + userId: z.string().uuid().optional(), + whmcsClientId: z.number().int().positive().optional(), + sfAccountId: z.string().optional(), + hasWhmcsMapping: z.boolean().optional(), + hasSfMapping: z.boolean().optional(), +}); + +// ============================================================================ +// Stats and Analytics Schemas +// ============================================================================ + +export const mappingStatsSchema = z.object({ + totalMappings: z.number().int().nonnegative(), + whmcsMappings: z.number().int().nonnegative(), + salesforceMappings: z.number().int().nonnegative(), + completeMappings: z.number().int().nonnegative(), + orphanedMappings: z.number().int().nonnegative(), +}); + +// ============================================================================ +// Bulk Operations Schemas +// ============================================================================ + +export const bulkMappingOperationSchema = z.object({ + operation: z.enum(["create", "update", "delete"]), + mappings: z.union([ + z.array(createMappingRequestSchema), + z.array(updateMappingRequestSchema), + z.array(z.string().uuid()), + ]), +}); + +export const bulkMappingResultSchema = z.object({ + successful: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + errors: z.array( + z.object({ + index: z.number().int().nonnegative(), + error: z.string(), + data: z.unknown(), + }) + ), +}); + +// ============================================================================ +// Inferred Types from Schemas (Schema-First Approach) +// ============================================================================ + +export type MappingSearchFilters = z.infer; +export type MappingStats = z.infer; +export type BulkMappingOperation = z.infer; +export type BulkMappingResult = z.infer; diff --git a/packages/domain/mappings/validation.ts b/packages/domain/mappings/validation.ts new file mode 100644 index 00000000..6b602019 --- /dev/null +++ b/packages/domain/mappings/validation.ts @@ -0,0 +1,203 @@ +/** + * ID Mapping Domain - Validation + * + * Pure business validation functions for ID mappings. + * These functions contain no infrastructure dependencies (no DB, no HTTP, no logging). + */ + +import { z } from "zod"; +import { + createMappingRequestSchema, + updateMappingRequestSchema, + userIdMappingSchema, +} from "./schema"; +import type { + CreateMappingRequest, + UpdateMappingRequest, + UserIdMapping, + MappingValidationResult, +} from "./contract"; + +/** + * Validate a create mapping request format + */ +export function validateCreateRequest(request: CreateMappingRequest): MappingValidationResult { + const validationResult = createMappingRequestSchema.safeParse(request); + + if (validationResult.success) { + const warnings: string[] = []; + if (!request.sfAccountId) { + warnings.push("Salesforce account ID not provided - mapping will be incomplete"); + } + return { isValid: true, errors: [], warnings }; + } + + const errors = validationResult.error.issues.map(issue => issue.message); + return { isValid: false, errors, warnings: [] }; +} + +/** + * Validate an update mapping request format + */ +export function validateUpdateRequest( + userId: string, + request: UpdateMappingRequest +): MappingValidationResult { + // First validate userId + const userIdValidation = z.string().uuid().safeParse(userId); + if (!userIdValidation.success) { + return { + isValid: false, + errors: ["User ID must be a valid UUID"], + warnings: [], + }; + } + + // Then validate the update request + const validationResult = updateMappingRequestSchema.safeParse(request); + + if (validationResult.success) { + return { isValid: true, errors: [], warnings: [] }; + } + + const errors = validationResult.error.issues.map(issue => issue.message); + return { isValid: false, errors, warnings: [] }; +} + +/** + * Validate an existing mapping + */ +export function validateExistingMapping(mapping: UserIdMapping): MappingValidationResult { + const validationResult = userIdMappingSchema.safeParse(mapping); + + if (validationResult.success) { + const warnings: string[] = []; + if (!mapping.sfAccountId) { + warnings.push("Mapping is missing Salesforce account ID"); + } + return { isValid: true, errors: [], warnings }; + } + + const errors = validationResult.error.issues.map(issue => issue.message); + return { isValid: false, errors, warnings: [] }; +} + +/** + * Validate bulk mappings + */ +export function validateBulkMappings( + mappings: CreateMappingRequest[] +): Array<{ index: number; validation: MappingValidationResult }> { + return mappings.map((mapping, index) => ({ + index, + validation: validateCreateRequest(mapping), + })); +} + +/** + * Validate no conflicts exist with existing mappings + * Business rule: Each userId, whmcsClientId should be unique + */ +export function validateNoConflicts( + request: CreateMappingRequest, + existingMappings: UserIdMapping[] +): MappingValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // First validate the request format + const formatValidation = validateCreateRequest(request); + if (!formatValidation.isValid) { + return formatValidation; + } + + // Check for conflicts + const duplicateUser = existingMappings.find(m => m.userId === request.userId); + if (duplicateUser) { + errors.push(`User ${request.userId} already has a mapping`); + } + + const duplicateWhmcs = existingMappings.find(m => m.whmcsClientId === request.whmcsClientId); + if (duplicateWhmcs) { + errors.push( + `WHMCS client ${request.whmcsClientId} is already mapped to user ${duplicateWhmcs.userId}` + ); + } + + if (request.sfAccountId) { + const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId); + if (duplicateSf) { + warnings.push( + `Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}` + ); + } + } + + return { isValid: errors.length === 0, errors, warnings }; +} + +/** + * Validate deletion constraints + * Business rule: Warn about data access impacts + */ +export function validateDeletion(mapping: UserIdMapping | null | undefined): MappingValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!mapping) { + errors.push("Cannot delete non-existent mapping"); + return { isValid: false, errors, warnings }; + } + + // Validate the mapping format + const formatValidation = validateExistingMapping(mapping); + if (!formatValidation.isValid) { + return formatValidation; + } + + warnings.push( + "Deleting this mapping will prevent access to WHMCS/Salesforce data for this user" + ); + if (mapping.sfAccountId) { + warnings.push( + "This mapping includes Salesforce integration - deletion will affect case management" + ); + } + + return { isValid: true, errors, warnings }; +} + +/** + * Sanitize and normalize a create mapping request + * + * Note: This performs basic string trimming before validation. + * The schema handles validation; this is purely for data cleanup. + */ +export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest { + return { + userId: request.userId?.trim(), + whmcsClientId: request.whmcsClientId, + sfAccountId: request.sfAccountId?.trim() || undefined, + }; +} + +/** + * Sanitize and normalize an update mapping request + * + * Note: This performs basic string trimming before validation. + * The schema handles validation; this is purely for data cleanup. + */ +export function sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest { + const sanitized: Partial = {}; + + if (request.whmcsClientId !== undefined) { + sanitized.whmcsClientId = request.whmcsClientId; + } + + if (request.sfAccountId !== undefined) { + sanitized.sfAccountId = request.sfAccountId?.trim() || undefined; + } + + return sanitized; +} + diff --git a/packages/domain/orders/contract.ts b/packages/domain/orders/contract.ts index a5cf8d4b..5279a51c 100644 --- a/packages/domain/orders/contract.ts +++ b/packages/domain/orders/contract.ts @@ -6,7 +6,7 @@ */ import type { SalesforceProductFieldMap } from "../catalog/contract"; -import type { SalesforceAccountFieldMap } from "../customer/contract"; +import type { SalesforceAccountFieldMap } from "../customer"; import type { UserIdMapping } from "../mappings/contract"; // ============================================================================ @@ -120,10 +120,6 @@ export type UserMapping = Pick { - const mapped = mapFulfillmentOrderItem(item, index); + orderDetails.items.forEach((item, index) => { + const mapped = mapOrderItemToWhmcs(item, index); whmcsItems.push(mapped); + if (mapped.billingCycle === "monthly") { serviceItems++; } else if (mapped.billingCycle === "onetime") { @@ -195,4 +180,3 @@ export function createOrderNotes(sfOrderId: string, additionalNotes?: string): s return notes.join("; "); } - diff --git a/packages/domain/orders/schema.ts b/packages/domain/orders/schema.ts index 55e1043b..aa671d0c 100644 --- a/packages/domain/orders/schema.ts +++ b/packages/domain/orders/schema.ts @@ -6,33 +6,6 @@ import { z } from "zod"; -// ============================================================================ -// Fulfillment Order Schemas -// ============================================================================ - -export const fulfillmentOrderProductSchema = z.object({ - id: z.string().optional(), - sku: z.string().optional(), - name: z.string().optional(), - itemClass: z.string().optional(), - whmcsProductId: z.string().optional(), - billingCycle: z.string().optional(), -}); - -export const fulfillmentOrderItemSchema = z.object({ - id: z.string(), - orderId: z.string(), - quantity: z.number().int().min(1), - product: fulfillmentOrderProductSchema.nullable(), -}); - -export const fulfillmentOrderDetailsSchema = z.object({ - id: z.string(), - orderNumber: z.string().optional(), - orderType: z.string().optional(), - items: z.array(fulfillmentOrderItemSchema), -}); - // ============================================================================ // Order Item Summary Schema // ============================================================================ @@ -247,11 +220,6 @@ export type SfOrderIdParam = z.infer; // Inferred Types from Schemas (Schema-First Approach) // ============================================================================ -// Fulfillment order types -export type FulfillmentOrderProduct = z.infer; -export type FulfillmentOrderItem = z.infer; -export type FulfillmentOrderDetails = z.infer; - // Order item types export type OrderItemSummary = z.infer; export type OrderItemDetails = z.infer; diff --git a/packages/domain/orders/validation.ts b/packages/domain/orders/validation.ts index c766ed46..ec05d608 100644 --- a/packages/domain/orders/validation.ts +++ b/packages/domain/orders/validation.ts @@ -83,6 +83,9 @@ export function getMainServiceSkus(skus: string[]): string[] { /** * Complete order validation including all SKU business rules * + * This schema delegates to the helper functions above for DRY validation. + * Helper functions are reusable in both schema refinements and imperative validation. + * * Validates: * - Basic order structure (from orderBusinessValidationSchema) * - SIM orders have service plan + activation fee @@ -91,48 +94,28 @@ export function getMainServiceSkus(skus: string[]): string[] { */ export const orderWithSkuValidationSchema = orderBusinessValidationSchema .refine( - (data) => { - if (data.orderType === "SIM") { - return hasSimServicePlan(data.skus); - } - return true; - }, + (data) => data.orderType !== "SIM" || hasSimServicePlan(data.skus), { message: "SIM orders must include a SIM service plan", path: ["skus"], } ) .refine( - (data) => { - if (data.orderType === "SIM") { - return hasSimActivationFee(data.skus); - } - return true; - }, + (data) => data.orderType !== "SIM" || hasSimActivationFee(data.skus), { message: "SIM orders require an activation fee", path: ["skus"], } ) .refine( - (data) => { - if (data.orderType === "VPN") { - return hasVpnActivationFee(data.skus); - } - return true; - }, + (data) => data.orderType !== "VPN" || hasVpnActivationFee(data.skus), { message: "VPN orders require an activation fee", path: ["skus"], } ) .refine( - (data) => { - if (data.orderType === "Internet") { - return hasInternetServicePlan(data.skus); - } - return true; - }, + (data) => data.orderType !== "Internet" || hasInternetServicePlan(data.skus), { message: "Internet orders require a service plan", path: ["skus"], diff --git a/packages/domain/package.json b/packages/domain/package.json index 4d333d41..512f12c3 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -22,6 +22,8 @@ "./common/*": "./dist/common/*", "./auth": "./dist/auth/index.js", "./auth/*": "./dist/auth/*", + "./customer": "./dist/customer/index.js", + "./customer/*": "./dist/customer/*", "./dashboard": "./dist/dashboard/index.js", "./dashboard/*": "./dist/dashboard/*", "./mappings": "./dist/mappings/index.js", diff --git a/packages/domain/payments/index.ts b/packages/domain/payments/index.ts index 98be77bd..a0855107 100644 --- a/packages/domain/payments/index.ts +++ b/packages/domain/payments/index.ts @@ -25,8 +25,11 @@ export type { // Provider adapters export * as Providers from "./providers"; -// Re-export provider response types +// Re-export provider raw types (request and response) export type { + // Request params + WhmcsGetPayMethodsParams, + // Response types WhmcsPaymentMethod, WhmcsPaymentMethodListResponse, WhmcsPaymentGateway, diff --git a/packages/domain/payments/providers/whmcs/raw.types.ts b/packages/domain/payments/providers/whmcs/raw.types.ts index 868a0436..162c5427 100644 --- a/packages/domain/payments/providers/whmcs/raw.types.ts +++ b/packages/domain/payments/providers/whmcs/raw.types.ts @@ -1,9 +1,30 @@ /** * WHMCS Payments Provider - Raw Types + * + * Type definitions for the WHMCS payment API contract: + * - Request parameter types (API inputs) + * - Response types (API outputs) */ import { z } from "zod"; +// ============================================================================ +// Request Parameter Types +// ============================================================================ + +/** + * Parameters for WHMCS GetPayMethods API + */ +export interface WhmcsGetPayMethodsParams extends Record { + clientid: number; + paymethodid?: number; + type?: "BankAccount" | "CreditCard"; +} + +// ============================================================================ +// Response Types +// ============================================================================ + export const whmcsPaymentMethodRawSchema = z.object({ id: z.number(), payment_type: z.string().optional(), diff --git a/packages/domain/subscriptions/index.ts b/packages/domain/subscriptions/index.ts index 361439b5..76a21936 100644 --- a/packages/domain/subscriptions/index.ts +++ b/packages/domain/subscriptions/index.ts @@ -20,12 +20,18 @@ export type { SubscriptionList, SubscriptionQueryParams, SubscriptionQuery, + SubscriptionStats, + SimActionResponse, + SimPlanChangeResult, } from './schema'; // Provider adapters export * as Providers from "./providers"; -// Re-export provider response types +// Re-export provider raw types (request and response) export type { + // Request params + WhmcsGetClientsProductsParams, + // Response types WhmcsProductListResponse, } from "./providers/whmcs/raw.types"; diff --git a/packages/domain/subscriptions/providers/whmcs/raw.types.ts b/packages/domain/subscriptions/providers/whmcs/raw.types.ts index 5c5fe5fb..eca03150 100644 --- a/packages/domain/subscriptions/providers/whmcs/raw.types.ts +++ b/packages/domain/subscriptions/providers/whmcs/raw.types.ts @@ -1,11 +1,36 @@ /** * WHMCS Subscriptions Provider - Raw Types * - * Type definitions for raw WHMCS API responses related to subscriptions/products. + * Type definitions for the WHMCS subscriptions API contract: + * - Request parameter types (API inputs) + * - Response types (API outputs) */ import { z } from "zod"; +// ============================================================================ +// Request Parameter Types +// ============================================================================ + +/** + * Parameters for WHMCS GetClientsProducts API + */ +export interface WhmcsGetClientsProductsParams { + clientid: number; + serviceid?: number; + pid?: number; + domain?: string; + limitstart?: number; + limitnum?: number; + orderby?: "id" | "productname" | "regdate" | "nextduedate"; + order?: "ASC" | "DESC"; + [key: string]: unknown; +} + +// ============================================================================ +// Response Types +// ============================================================================ + // Custom field structure export const whmcsCustomFieldSchema = z.object({ id: z.number().optional(), diff --git a/packages/domain/subscriptions/schema.ts b/packages/domain/subscriptions/schema.ts index 0be8e405..848f25dd 100644 --- a/packages/domain/subscriptions/schema.ts +++ b/packages/domain/subscriptions/schema.ts @@ -75,6 +75,39 @@ export type SubscriptionQueryParams = z.infer; export type SubscriptionCycle = z.infer; export type Subscription = z.infer; export type SubscriptionList = z.infer; +export type SubscriptionStats = z.infer; +export type SimActionResponse = z.infer; +export type SimPlanChangeResult = z.infer;