Refactor WHMCS integration and user management to align with updated domain structure. Removed deprecated validation utilities and types, enhancing type safety and maintainability. Streamlined import paths and module exports for consistency, ensuring clear separation of concerns in data handling. Updated user and address management to reflect new schemas, improving validation and organization across the application.

This commit is contained in:
barsa 2025-10-08 16:31:42 +09:00
parent b19da24edd
commit 55489cad20
89 changed files with 3866 additions and 1755 deletions

249
AUTH-SCHEMA-IMPROVEMENTS.md Normal file
View File

@ -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<typeof userProfileSchema>;
// 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! 🎉

116
CUSTOMER-DOMAIN-CLEANUP.md Normal file
View File

@ -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.**

View File

@ -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! 🎯**

255
CUSTOMER-DOMAIN-NAMING.md Normal file
View File

@ -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<typeof userAuthSchema>;
// ============================================================================
// 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<typeof customerDataSchema>;
// ============================================================================
// 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<typeof userSchema>;
```
## 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<UserAuth> {
return this.getAuthState(payload.sub);
}
// In profile API - need complete user
async getProfile(userId: string): Promise<User> {
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<typeof authResponseSchema>;
```
## 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)

541
CUSTOMER-DOMAIN-REFACTOR.md Normal file
View File

@ -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<typeof customerAddressSchema>;
export type PortalUser = z.infer<typeof portalUserSchema>;
export type CustomerProfile = z.infer<typeof customerProfileSchema>;
export type UserProfile = z.infer<typeof userProfileSchema>;
// ... 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<typeof loginRequestSchema>;
export type AuthTokens = z.infer<typeof authTokensSchema>;
export type AuthResponse = z.infer<typeof authResponseSchema>;
export type SignupResult = z.infer<typeof signupResultSchema>;
// ... 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<UserProfile> {
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<PortalUser> {
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

View File

@ -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<InvoiceListQuery>): 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)

View File

@ -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<UserMappingInfo> {
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!**

View File

@ -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");
}
}

View File

@ -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);
}

View File

@ -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<Customer | null> {
async getClientData(clientId: number) {
const key = this.buildClientKey(clientId);
return this.get<Customer>(key, "client");
return this.get(key, "client");
}
/**
* Cache client data
*/
async setClientData(clientId: number, data: Customer): Promise<void> {
async setClientData(clientId: number, data: ReturnType<typeof CustomerProviders.Whmcs.transformWhmcsClientResponse>) {
const key = this.buildClientKey(clientId);
await this.set(key, data, "client", [`client:${clientId}`]);
}

View File

@ -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,

View File

@ -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";

View File

@ -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<Customer> {
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<Customer> {
async getClientDetailsByEmail(email: string) {
try {
const response = await this.connectionService.getClientDetailsByEmail(email);

View File

@ -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,

View File

@ -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,

View File

@ -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()

View File

@ -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 {

View File

@ -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<string, string>;
language?: string;
clientip?: string;
notes?: string;
marketing_emails_opt_in?: boolean;
no_email?: boolean;
[key: string]: unknown;
}
export interface WhmcsGetPayMethodsParams extends Record<string, unknown> {
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;
}

View File

@ -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<Customer> {
async getClientDetails(clientId: number) {
return this.clientService.getClientDetails(clientId);
}
/**
* Get client details by email
* Returns internal WhmcsClient (type inferred)
*/
async getClientDetailsByEmail(email: string): Promise<Customer> {
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<CustomerAddress> {
async getClientAddress(clientId: number): Promise<Address> {
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<CustomerAddress>): Promise<void> {
async updateClientAddress(clientId: number, address: Partial<Address>): Promise<void> {
const updateData = CustomerProviders.Whmcs.prepareWhmcsClientAddressUpdate(address);
if (Object.keys(updateData).length === 0) return;
await this.clientService.updateClient(clientId, updateData);

View File

@ -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<SsoLinkResponse> {
try {
// Production-safe logging - no sensitive data
this.logger.log("Creating SSO link request");

View File

@ -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 };

View File

@ -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");
}

View File

@ -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<string | number>("BCRYPT_ROUNDS", 12);
const saltRounds =

View File

@ -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(

View File

@ -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,

View File

@ -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<AuthenticatedUser> {
}): Promise<UserAuth> {
// Validate payload structure
if (!payload.sub || !payload.email) {
throw new Error("Invalid JWT payload");

View File

@ -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()

View File

@ -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";

View File

@ -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";

View File

@ -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<UpdateMappingRequest> = {};
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);
}
}

View File

@ -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";

View File

@ -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<InvoiceList> {
return this.invoicesService.getInvoices(req.user.id, query);
}
@ -72,10 +76,8 @@ export class InvoicesController {
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) invoiceId: number
): Promise<Invoice> {
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<InvoiceSsoLink> {
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<InvoicePaymentLink> {
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)) {

View File

@ -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],
})

View File

@ -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

View File

@ -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<InvoiceList> {
const { page = 1, limit = 10, status } = options;
async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise<InvoiceList> {
// 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<Invoice> {
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<InvoiceListQuery> = {}
): Promise<InvoiceList> {
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<InvoiceList> {
async getUnpaidInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Unpaid", options);
}
/**
* Get overdue invoices for a user
*/
async getOverdueInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
async getOverdueInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Overdue", options);
}
/**
* Get paid invoices for a user
*/
async getPaidInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
async getPaidInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Paid", options);
}
@ -167,7 +169,7 @@ export class InvoiceRetrievalService {
*/
async getCancelledInvoices(
userId: string,
options: PaginationOptions = {}
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Cancelled", options);
}
@ -177,7 +179,7 @@ export class InvoiceRetrievalService {
*/
async getCollectionsInvoices(
userId: string,
options: PaginationOptions = {}
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Collections", options);
}
@ -186,13 +188,19 @@ export class InvoiceRetrievalService {
* Get user mapping with validation
*/
private async getUserMapping(userId: string): Promise<UserMappingInfo> {
// 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,

View File

@ -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<InvoiceList> {
async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise<InvoiceList> {
const startTime = Date.now();
try {
@ -67,7 +69,7 @@ export class InvoicesOrchestratorService {
async getInvoicesByStatus(
userId: string,
status: InvoiceStatus,
options: PaginationOptions = {}
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
const startTime = Date.now();
@ -84,21 +86,21 @@ export class InvoicesOrchestratorService {
/**
* Get unpaid invoices for a user
*/
async getUnpaidInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
async getUnpaidInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> {
return this.retrievalService.getUnpaidInvoices(userId, options);
}
/**
* Get overdue invoices for a user
*/
async getOverdueInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
async getOverdueInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> {
return this.retrievalService.getOverdueInvoices(userId, options);
}
/**
* Get paid invoices for a user
*/
async getPaidInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
async getPaidInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> {
return this.retrievalService.getPaidInvoices(userId, options);
}
@ -107,7 +109,7 @@ export class InvoicesOrchestratorService {
*/
async getCancelledInvoices(
userId: string,
options: PaginationOptions = {}
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
return this.retrievalService.getCancelledInvoices(userId, options);
}
@ -117,7 +119,7 @@ export class InvoicesOrchestratorService {
*/
async getCollectionsInvoices(
userId: string,
options: PaginationOptions = {}
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
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,
};
}
}

View File

@ -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;
};
}

View File

@ -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;
}

View File

@ -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<PaginationOptions> {
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);
}
}

View File

@ -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<string, unknown>,
idempotencyKey: string
): Promise<OrderFulfillmentContext> {
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<void>
): Promise<void> {
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<string, unknown> {
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<string, unknown>;
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<string, unknown>;
const productRaw = record.product;
const product =
productRaw && typeof productRaw === "object"
? (productRaw as Record<string, unknown>)
: 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

View File

@ -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}`);

View File

@ -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";
/**

View File

@ -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<string, unknown>;
}
@ -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")
);

View File

@ -1,11 +0,0 @@
export type {
FulfillmentOrderProduct,
FulfillmentOrderItem,
FulfillmentOrderDetails,
} from "@customer-portal/domain/orders";
export {
fulfillmentOrderProductSchema,
fulfillmentOrderItemSchema,
fulfillmentOrderDetailsSchema,
} from "@customer-portal/domain/orders";

View File

@ -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()

View File

@ -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 {

View File

@ -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 {

View File

@ -15,7 +15,7 @@ import type {
SimCancelRequest,
SimTopUpHistoryRequest,
SimFeaturesUpdateRequest,
} from "../types/sim-requests.types";
} from "@customer-portal/domain/sim";
@Injectable()
export class SimOrchestratorService {

View File

@ -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 {

View File

@ -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 {

View File

@ -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()

View File

@ -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";

View File

@ -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<SubscriptionStats> {
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<SimActionResponse> {
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<SimPlanChangeResult> {
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<SimActionResponse> {
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<SimActionResponse> {
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<SimActionResponse> {
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
return { success: true, message: "SIM features updated successfully" };
}

View File

@ -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;

View File

@ -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<AuthenticatedUser | null> {
async findByEmail(email: string): Promise<User | null> {
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<AuthenticatedUser | null> {
async findById(id: string): Promise<User | null> {
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<AuthenticatedUser> {
async getProfile(userId: string): Promise<User> {
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<PrismaUser>): Promise<AuthenticatedUser> {
async create(userData: Partial<PrismaUser>): Promise<User> {
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<AuthenticatedUser> {
async update(id: string, userData: UserUpdateData): Promise<User> {
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<AuthenticatedUser> {
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> {
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 });
}

137
bff-validation-migration.md Normal file
View File

@ -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

413
docs/VALIDATION_PATTERNS.md Normal file
View File

@ -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<typeof createOrderRequestSchema>;
```
**❌ 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.

View File

@ -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<string, unknown>;
}
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";

View File

@ -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";

View File

@ -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<typeof ssoLinkRequestSchema>;
export type CheckPasswordNeededRequest = z.infer<typeof checkPasswordNeededRequestSchema>;
export type RefreshTokenRequest = z.infer<typeof refreshTokenRequestSchema>;
// Response types
// Token types
export type AuthTokens = z.infer<typeof authTokensSchema>;
// Response types
export type AuthResponse = z.infer<typeof authResponseSchema>;
export type SignupResult = z.infer<typeof signupResultSchema>;
export type PasswordChangeResult = z.infer<typeof passwordChangeResultSchema>;
export type SsoLinkResponse = z.infer<typeof ssoLinkResponseSchema>;
export type CheckPasswordNeededResponse = z.infer<typeof checkPasswordNeededResponseSchema>;
// ============================================================================
// 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;
}

View File

@ -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,

View File

@ -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(),

View File

@ -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";

View File

@ -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;
}

View File

@ -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';

View File

@ -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";

View File

@ -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";

View File

@ -0,0 +1,9 @@
/**
* Portal Provider
*
* Handles mapping from Prisma (portal database) to UserAuth domain type
*/
export * from "./mapper";
export * from "./types";

View File

@ -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(),
});
}

View File

@ -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;
}

View File

@ -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";

View File

@ -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<string, string> | 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<string, string> = {};
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<typeof whmcsUserSchema>) => 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<CustomerAddress>
address: Partial<Address>
): Record<string, unknown> => {
const update: Record<string, unknown> = {};
if (address.address1 !== undefined) update.address1 = address.address1 ?? "";

View File

@ -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<string, string>;
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()]);

View File

@ -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<string, unknown> = {
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<typeof addressFormSchema>;
// ============================================================================
// 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<typeof customerAddressSchema>;
export type CustomerEmailPreferences = z.infer<typeof customerEmailPreferencesSchema>;
export type CustomerUser = z.infer<typeof customerUserSchema>;
export type CustomerStats = z.infer<typeof customerStatsSchema>;
export type Customer = z.infer<typeof customerSchema>;
export type User = z.infer<typeof userSchema>;
export type UserAuth = z.infer<typeof userAuthSchema>;
export type UserRole = "USER" | "ADMIN";
export type Address = z.infer<typeof addressSchema>;
export type AddressFormData = z.infer<typeof addressFormSchema>;
// Type aliases
export type Address = CustomerAddress;
// ============================================================================
// Internal Types (For Providers)
// ============================================================================
export type WhmcsClient = z.infer<typeof whmcsClientSchema>;
export type EmailPreferences = z.infer<typeof emailPreferencesSchema>;
export type SubUser = z.infer<typeof subUserSchema>;
export type Stats = z.infer<typeof statsSchema>;
// Export schemas for provider use
export { emailPreferencesSchema, subUserSchema, statsSchema };

View File

@ -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[];
}

View File

@ -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";

View File

@ -34,3 +34,61 @@ export const userIdMappingSchema: z.ZodType<UserIdMapping> = 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<typeof mappingSearchFiltersSchema>;
export type MappingStats = z.infer<typeof mappingStatsSchema>;
export type BulkMappingOperation = z.infer<typeof bulkMappingOperationSchema>;
export type BulkMappingResult = z.infer<typeof bulkMappingResultSchema>;

View File

@ -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<UpdateMappingRequest> = {};
if (request.whmcsClientId !== undefined) {
sanitized.whmcsClientId = request.whmcsClientId;
}
if (request.sfAccountId !== undefined) {
sanitized.sfAccountId = request.sfAccountId?.trim() || undefined;
}
return sanitized;
}

View File

@ -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<UserIdMapping, "userId" | "whmcsClientId" | "sfAc
// ============================================================================
export type {
// Fulfillment order types
FulfillmentOrderProduct,
FulfillmentOrderItem,
FulfillmentOrderDetails,
// Order item types
OrderItemSummary,
OrderItemDetails,

View File

@ -29,10 +29,6 @@ export * from "./validation";
// Re-export types for convenience
export type {
// Fulfillment order types
FulfillmentOrderProduct,
FulfillmentOrderItem,
FulfillmentOrderDetails,
// Order item types
OrderItemSummary,
OrderItemDetails,

View File

@ -4,29 +4,12 @@
* Transforms normalized order data to WHMCS API format.
*/
import type { FulfillmentOrderItem } from "../../contract";
import type { OrderDetails, OrderItemDetails } from "../../contract";
import {
type WhmcsOrderItem,
type WhmcsAddOrderParams,
type WhmcsAddOrderPayload,
whmcsOrderItemSchema,
} from "./raw.types";
import { z } from "zod";
const fulfillmentOrderItemSchema = z.object({
id: z.string(),
orderId: z.string(),
quantity: z.number().int().min(1),
product: z
.object({
id: z.string().optional(),
sku: z.string().optional(),
itemClass: z.string().optional(),
whmcsProductId: z.string().min(1),
billingCycle: z.string().min(1),
})
.nullable(),
});
export interface OrderItemMappingResult {
whmcsItems: WhmcsOrderItem[];
@ -37,7 +20,9 @@ export interface OrderItemMappingResult {
};
}
function normalizeBillingCycle(cycle: string): WhmcsOrderItem["billingCycle"] {
function normalizeBillingCycle(cycle: string | undefined): WhmcsOrderItem["billingCycle"] {
if (!cycle) return "monthly"; // Default
const normalized = cycle.trim().toLowerCase();
if (normalized.includes("monthly")) return "monthly";
if (normalized.includes("one")) return "onetime";
@ -48,34 +33,33 @@ function normalizeBillingCycle(cycle: string): WhmcsOrderItem["billingCycle"] {
}
/**
* Map a single fulfillment order item to WHMCS format
* Map a single order item to WHMCS format
*/
export function mapFulfillmentOrderItem(
item: FulfillmentOrderItem,
export function mapOrderItemToWhmcs(
item: OrderItemDetails,
index = 0
): WhmcsOrderItem {
const parsed = fulfillmentOrderItemSchema.parse(item);
if (!parsed.product) {
throw new Error(`Order item ${index} missing product information`);
if (!item.product?.whmcsProductId) {
throw new Error(`Order item ${index} missing WHMCS product ID`);
}
const whmcsItem: WhmcsOrderItem = {
productId: parsed.product.whmcsProductId,
billingCycle: normalizeBillingCycle(parsed.product.billingCycle),
quantity: parsed.quantity,
productId: item.product.whmcsProductId,
billingCycle: normalizeBillingCycle(item.billingCycle),
quantity: item.quantity,
};
return whmcsItem;
}
/**
* Map multiple fulfillment order items to WHMCS format
* Map order details to WHMCS items format
* Extracts items from OrderDetails and transforms to WHMCS API format
*/
export function mapFulfillmentOrderItems(
items: FulfillmentOrderItem[]
export function mapOrderToWhmcsItems(
orderDetails: OrderDetails
): OrderItemMappingResult {
if (!Array.isArray(items) || items.length === 0) {
if (!orderDetails.items || orderDetails.items.length === 0) {
throw new Error("No order items provided for WHMCS mapping");
}
@ -83,9 +67,10 @@ export function mapFulfillmentOrderItems(
let serviceItems = 0;
let activationItems = 0;
items.forEach((item, index) => {
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("; ");
}

View File

@ -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<typeof sfOrderIdParamSchema>;
// Inferred Types from Schemas (Schema-First Approach)
// ============================================================================
// Fulfillment order types
export type FulfillmentOrderProduct = z.infer<typeof fulfillmentOrderProductSchema>;
export type FulfillmentOrderItem = z.infer<typeof fulfillmentOrderItemSchema>;
export type FulfillmentOrderDetails = z.infer<typeof fulfillmentOrderDetailsSchema>;
// Order item types
export type OrderItemSummary = z.infer<typeof orderItemSummarySchema>;
export type OrderItemDetails = z.infer<typeof orderItemDetailsSchema>;

View File

@ -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"],

View File

@ -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",

View File

@ -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,

View File

@ -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<string, unknown> {
clientid: number;
paymethodid?: number;
type?: "BankAccount" | "CreditCard";
}
// ============================================================================
// Response Types
// ============================================================================
export const whmcsPaymentMethodRawSchema = z.object({
id: z.number(),
payment_type: z.string().optional(),

View File

@ -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";

View File

@ -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(),

View File

@ -75,6 +75,39 @@ export type SubscriptionQueryParams = z.infer<typeof subscriptionQueryParamsSche
export const subscriptionQuerySchema = subscriptionQueryParamsSchema;
export type SubscriptionQuery = SubscriptionQueryParams;
// ============================================================================
// Response Schemas
// ============================================================================
/**
* Schema for subscription statistics
*/
export const subscriptionStatsSchema = z.object({
total: z.number().int().nonnegative(),
active: z.number().int().nonnegative(),
completed: z.number().int().nonnegative(),
cancelled: z.number().int().nonnegative(),
});
/**
* Schema for SIM action responses (top-up, cancellation, feature updates)
*/
export const simActionResponseSchema = z.object({
success: z.boolean(),
message: z.string(),
data: z.unknown().optional(),
});
/**
* Schema for SIM plan change result with IP addresses
*/
export const simPlanChangeResultSchema = z.object({
success: z.boolean(),
message: z.string(),
ipv4: z.string().optional(),
ipv6: z.string().optional(),
});
// ============================================================================
// Inferred Types from Schemas (Schema-First Approach)
// ============================================================================
@ -83,3 +116,6 @@ export type SubscriptionStatus = z.infer<typeof subscriptionStatusSchema>;
export type SubscriptionCycle = z.infer<typeof subscriptionCycleSchema>;
export type Subscription = z.infer<typeof subscriptionSchema>;
export type SubscriptionList = z.infer<typeof subscriptionListSchema>;
export type SubscriptionStats = z.infer<typeof subscriptionStatsSchema>;
export type SimActionResponse = z.infer<typeof simActionResponseSchema>;
export type SimPlanChangeResult = z.infer<typeof simPlanChangeResultSchema>;