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;