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:
parent
b19da24edd
commit
55489cad20
249
AUTH-SCHEMA-IMPROVEMENTS.md
Normal file
249
AUTH-SCHEMA-IMPROVEMENTS.md
Normal 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
116
CUSTOMER-DOMAIN-CLEANUP.md
Normal 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.**
|
||||||
|
|
||||||
177
CUSTOMER-DOMAIN-FINAL-CLEANUP.md
Normal file
177
CUSTOMER-DOMAIN-FINAL-CLEANUP.md
Normal 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
255
CUSTOMER-DOMAIN-NAMING.md
Normal 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
541
CUSTOMER-DOMAIN-REFACTOR.md
Normal 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
|
||||||
|
|
||||||
167
INVOICE-VALIDATION-CLEANUP-PLAN.md
Normal file
167
INVOICE-VALIDATION-CLEANUP-PLAN.md
Normal 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)
|
||||||
|
|
||||||
157
VALIDATION-CLEANUP-COMPLETE.md
Normal file
157
VALIDATION-CLEANUP-COMPLETE.md
Normal 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!**
|
||||||
|
|
||||||
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,38 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* User DB Mapper
|
* 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.
|
* NOTE: This is an infrastructure adapter - Prisma is BFF's ORM implementation detail.
|
||||||
* Domain layer should not know about Prisma types.
|
* The domain provider handles the actual mapping logic.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { User as PrismaUser } from "@prisma/client";
|
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
|
* Maps Prisma User entity to Domain UserAuth type
|
||||||
* NOTE: Profile fields must be fetched from WHMCS - this only maps auth state
|
*
|
||||||
|
* 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 {
|
export function mapPrismaUserToDomain(user: PrismaUser): UserAuth {
|
||||||
return {
|
// Convert @prisma/client User to domain PrismaUserRaw
|
||||||
|
const prismaUserRaw: PrismaUserRaw = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
passwordHash: user.passwordHash,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
mfaEnabled: !!user.mfaSecret,
|
mfaSecret: user.mfaSecret,
|
||||||
emailVerified: user.emailVerified,
|
emailVerified: user.emailVerified,
|
||||||
lastLoginAt: user.lastLoginAt?.toISOString(),
|
failedLoginAttempts: user.failedLoginAttempts,
|
||||||
// Profile fields null - fetched from WHMCS
|
lockedUntil: user.lockedUntil,
|
||||||
firstname: null,
|
lastLoginAt: user.lastLoginAt,
|
||||||
lastname: null,
|
createdAt: user.createdAt,
|
||||||
fullname: null,
|
updatedAt: user.updatedAt,
|
||||||
companyname: null,
|
|
||||||
phonenumber: null,
|
|
||||||
language: null,
|
|
||||||
currencyCode: null,
|
|
||||||
address: undefined,
|
|
||||||
createdAt: user.createdAt.toISOString(),
|
|
||||||
updatedAt: user.updatedAt.toISOString(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Use domain provider mapper
|
||||||
|
return mapPrismaUserToUserAuth(prismaUserRaw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { CacheService } from "@bff/infra/cache/cache.service";
|
|||||||
import { Invoice, InvoiceList } from "@customer-portal/domain/billing";
|
import { Invoice, InvoiceList } from "@customer-portal/domain/billing";
|
||||||
import { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
|
import { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
|
||||||
import { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments";
|
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 {
|
export interface CacheOptions {
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
@ -146,16 +146,17 @@ export class WhmcsCacheService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cached client data
|
* 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);
|
const key = this.buildClientKey(clientId);
|
||||||
return this.get<Customer>(key, "client");
|
return this.get(key, "client");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache client data
|
* 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);
|
const key = this.buildClientKey(clientId);
|
||||||
await this.set(key, data, "client", [`client:${clientId}`]);
|
await this.set(key, data, "client", [`client:${clientId}`]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import type {
|
import type {
|
||||||
WhmcsClientResponse,
|
WhmcsClientResponse,
|
||||||
WhmcsGetInvoicesParams,
|
|
||||||
WhmcsGetClientsProductsParams,
|
|
||||||
WhmcsCreateSsoTokenParams,
|
WhmcsCreateSsoTokenParams,
|
||||||
WhmcsValidateLoginParams,
|
WhmcsValidateLoginParams,
|
||||||
WhmcsAddClientParams,
|
WhmcsAddClientParams,
|
||||||
WhmcsGetPayMethodsParams,
|
} from "@customer-portal/domain/customer";
|
||||||
|
import type {
|
||||||
|
WhmcsGetInvoicesParams,
|
||||||
WhmcsCreateInvoiceParams,
|
WhmcsCreateInvoiceParams,
|
||||||
WhmcsUpdateInvoiceParams,
|
WhmcsUpdateInvoiceParams,
|
||||||
WhmcsCapturePaymentParams,
|
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 {
|
import type {
|
||||||
WhmcsInvoiceListResponse,
|
WhmcsInvoiceListResponse,
|
||||||
WhmcsInvoiceResponse,
|
WhmcsInvoiceResponse,
|
||||||
|
|||||||
@ -9,14 +9,16 @@ import { WhmcsRequestQueueService } from "@bff/core/queue/services/whmcs-request
|
|||||||
import type {
|
import type {
|
||||||
WhmcsAddClientParams,
|
WhmcsAddClientParams,
|
||||||
WhmcsValidateLoginParams,
|
WhmcsValidateLoginParams,
|
||||||
WhmcsGetInvoicesParams,
|
|
||||||
WhmcsGetClientsProductsParams,
|
|
||||||
WhmcsGetPayMethodsParams,
|
|
||||||
WhmcsCreateSsoTokenParams,
|
WhmcsCreateSsoTokenParams,
|
||||||
|
} from "@customer-portal/domain/customer";
|
||||||
|
import type {
|
||||||
|
WhmcsGetInvoicesParams,
|
||||||
WhmcsCreateInvoiceParams,
|
WhmcsCreateInvoiceParams,
|
||||||
WhmcsUpdateInvoiceParams,
|
WhmcsUpdateInvoiceParams,
|
||||||
WhmcsCapturePaymentParams,
|
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 { WhmcsErrorResponse } from "@customer-portal/domain/common";
|
||||||
import type { WhmcsRequestOptions, WhmcsConnectionStats } from "../types/connection.types";
|
import type { WhmcsRequestOptions, WhmcsConnectionStats } from "../types/connection.types";
|
||||||
|
|
||||||
|
|||||||
@ -3,19 +3,18 @@ import { Logger } from "nestjs-pino";
|
|||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
||||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||||
import {
|
import type {
|
||||||
WhmcsValidateLoginParams,
|
WhmcsValidateLoginParams,
|
||||||
WhmcsAddClientParams,
|
WhmcsAddClientParams,
|
||||||
WhmcsClientResponse,
|
WhmcsClientResponse,
|
||||||
} from "../types/whmcs-api.types";
|
} from "@customer-portal/domain/customer";
|
||||||
import type {
|
import type {
|
||||||
WhmcsAddClientResponse,
|
WhmcsAddClientResponse,
|
||||||
WhmcsValidateLoginResponse,
|
WhmcsValidateLoginResponse,
|
||||||
} from "@customer-portal/domain/customer";
|
} from "@customer-portal/domain/customer";
|
||||||
import {
|
import {
|
||||||
Providers as CustomerProviders,
|
Providers as CustomerProviders,
|
||||||
type Customer,
|
type Address,
|
||||||
type CustomerAddress,
|
|
||||||
} from "@customer-portal/domain/customer";
|
} from "@customer-portal/domain/customer";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -56,8 +55,9 @@ export class WhmcsClientService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get client details by ID
|
* Get client details by ID
|
||||||
|
* Returns WhmcsClient (type inferred from domain mapper)
|
||||||
*/
|
*/
|
||||||
async getClientDetails(clientId: number): Promise<Customer> {
|
async getClientDetails(clientId: number) {
|
||||||
try {
|
try {
|
||||||
// Try cache first
|
// Try cache first
|
||||||
const cached = await this.cacheService.getClientData(clientId);
|
const cached = await this.cacheService.getClientData(clientId);
|
||||||
@ -92,8 +92,9 @@ export class WhmcsClientService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get client details by email
|
* Get client details by email
|
||||||
|
* Returns WhmcsClient (type inferred from domain mapper)
|
||||||
*/
|
*/
|
||||||
async getClientDetailsByEmail(email: string): Promise<Customer> {
|
async getClientDetailsByEmail(email: string) {
|
||||||
try {
|
try {
|
||||||
const response = await this.connectionService.getClientDetailsByEmail(email);
|
const response = await this.connectionService.getClientDetailsByEmail(email);
|
||||||
|
|
||||||
|
|||||||
@ -5,12 +5,12 @@ import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema, Providers } fro
|
|||||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
||||||
import { WhmcsCurrencyService } from "./whmcs-currency.service";
|
import { WhmcsCurrencyService } from "./whmcs-currency.service";
|
||||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||||
import {
|
import type {
|
||||||
WhmcsGetInvoicesParams,
|
WhmcsGetInvoicesParams,
|
||||||
WhmcsCreateInvoiceParams,
|
WhmcsCreateInvoiceParams,
|
||||||
WhmcsUpdateInvoiceParams,
|
WhmcsUpdateInvoiceParams,
|
||||||
WhmcsCapturePaymentParams,
|
WhmcsCapturePaymentParams,
|
||||||
} from "../types/whmcs-api.types";
|
} from "@customer-portal/domain/billing";
|
||||||
import type {
|
import type {
|
||||||
WhmcsInvoiceListResponse,
|
WhmcsInvoiceListResponse,
|
||||||
WhmcsInvoiceResponse,
|
WhmcsInvoiceResponse,
|
||||||
|
|||||||
@ -10,10 +10,8 @@ import {
|
|||||||
} from "@customer-portal/domain/payments";
|
} from "@customer-portal/domain/payments";
|
||||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
||||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||||
import type {
|
import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer";
|
||||||
WhmcsCreateSsoTokenParams,
|
import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments";
|
||||||
WhmcsGetPayMethodsParams,
|
|
||||||
} from "../types/whmcs-api.types";
|
|
||||||
import type {
|
import type {
|
||||||
WhmcsPaymentMethod,
|
WhmcsPaymentMethod,
|
||||||
WhmcsPaymentMethodListResponse,
|
WhmcsPaymentMethodListResponse,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
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";
|
import type { WhmcsSsoResponse } from "@customer-portal/domain/customer";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { Subscription, SubscriptionList, Providers } from "@customer-portal/doma
|
|||||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
||||||
import { WhmcsCurrencyService } from "./whmcs-currency.service";
|
import { WhmcsCurrencyService } from "./whmcs-currency.service";
|
||||||
import { WhmcsCacheService } from "../cache/whmcs-cache.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";
|
import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions";
|
||||||
|
|
||||||
export interface SubscriptionFilters {
|
export interface SubscriptionFilters {
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -3,7 +3,7 @@ import { Injectable, Inject } from "@nestjs/common";
|
|||||||
import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
|
import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
|
||||||
import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
|
import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
|
||||||
import type { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments";
|
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 { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service";
|
||||||
import { WhmcsInvoiceService, InvoiceFilters } from "./services/whmcs-invoice.service";
|
import { WhmcsInvoiceService, InvoiceFilters } from "./services/whmcs-invoice.service";
|
||||||
import {
|
import {
|
||||||
@ -14,11 +14,11 @@ import { WhmcsClientService } from "./services/whmcs-client.service";
|
|||||||
import { WhmcsPaymentService } from "./services/whmcs-payment.service";
|
import { WhmcsPaymentService } from "./services/whmcs-payment.service";
|
||||||
import { WhmcsSsoService } from "./services/whmcs-sso.service";
|
import { WhmcsSsoService } from "./services/whmcs-sso.service";
|
||||||
import { WhmcsOrderService } from "./services/whmcs-order.service";
|
import { WhmcsOrderService } from "./services/whmcs-order.service";
|
||||||
import {
|
import type {
|
||||||
WhmcsAddClientParams,
|
WhmcsAddClientParams,
|
||||||
WhmcsClientResponse,
|
WhmcsClientResponse,
|
||||||
WhmcsGetClientsProductsParams,
|
} from "@customer-portal/domain/customer";
|
||||||
} from "./types/whmcs-api.types";
|
import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions";
|
||||||
import type {
|
import type {
|
||||||
WhmcsProductListResponse,
|
WhmcsProductListResponse,
|
||||||
} from "@customer-portal/domain/subscriptions";
|
} from "@customer-portal/domain/subscriptions";
|
||||||
@ -129,15 +129,17 @@ export class WhmcsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get client details by ID
|
* 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);
|
return this.clientService.getClientDetails(clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get client details by email
|
* 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);
|
return this.clientService.getClientDetailsByEmail(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,12 +156,16 @@ export class WhmcsService {
|
|||||||
/**
|
/**
|
||||||
* Convenience helpers for address get/update on WHMCS client
|
* 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);
|
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);
|
const updateData = CustomerProviders.Whmcs.prepareWhmcsClientAddressUpdate(address);
|
||||||
if (Object.keys(updateData).length === 0) return;
|
if (Object.keys(updateData).length === 0) return;
|
||||||
await this.clientService.updateClient(clientId, updateData);
|
await this.clientService.updateClient(clientId, updateData);
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import {
|
|||||||
type LinkWhmcsRequest,
|
type LinkWhmcsRequest,
|
||||||
type SetPasswordRequest,
|
type SetPasswordRequest,
|
||||||
type ChangePasswordRequest,
|
type ChangePasswordRequest,
|
||||||
|
type SsoLinkResponse,
|
||||||
|
type CheckPasswordNeededResponse,
|
||||||
signupRequestSchema,
|
signupRequestSchema,
|
||||||
validateSignupRequestSchema,
|
validateSignupRequestSchema,
|
||||||
linkWhmcsRequestSchema,
|
linkWhmcsRequestSchema,
|
||||||
@ -327,7 +329,7 @@ export class AuthFacade {
|
|||||||
async createSsoLink(
|
async createSsoLink(
|
||||||
userId: string,
|
userId: string,
|
||||||
destination?: string
|
destination?: string
|
||||||
): Promise<{ url: string; expiresAt: string }> {
|
): Promise<SsoLinkResponse> {
|
||||||
try {
|
try {
|
||||||
// Production-safe logging - no sensitive data
|
// Production-safe logging - no sensitive data
|
||||||
this.logger.log("Creating SSO link request");
|
this.logger.log("Creating SSO link request");
|
||||||
|
|||||||
@ -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";
|
import type { Request } from "express";
|
||||||
|
|
||||||
export type RequestWithUser = Request & { user: AuthenticatedUser };
|
export type RequestWithUser = Request & { user: User };
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { Redis } from "ioredis";
|
import { Redis } from "ioredis";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { randomBytes, createHash } from "crypto";
|
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 { UsersService } from "@bff/modules/users/users.service";
|
||||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||||
|
|
||||||
@ -195,7 +196,7 @@ export class AuthTokenService {
|
|||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
}
|
}
|
||||||
): Promise<{ tokens: AuthTokens; user: AuthenticatedUser }> {
|
): Promise<{ tokens: AuthTokens; user: User }> {
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new UnauthorizedException("Invalid refresh token");
|
throw new UnauthorizedException("Invalid refresh token");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,17 +12,13 @@ import { AuthTokenService } from "../../token/token.service";
|
|||||||
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service";
|
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service";
|
||||||
import {
|
import {
|
||||||
type AuthTokens,
|
type AuthTokens,
|
||||||
type UserProfile,
|
type PasswordChangeResult,
|
||||||
type ChangePasswordRequest,
|
type ChangePasswordRequest,
|
||||||
changePasswordRequestSchema,
|
changePasswordRequestSchema,
|
||||||
} from "@customer-portal/domain/auth";
|
} from "@customer-portal/domain/auth";
|
||||||
|
import type { User } from "@customer-portal/domain/customer";
|
||||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||||
|
|
||||||
export interface PasswordChangeResult {
|
|
||||||
user: UserProfile;
|
|
||||||
tokens: AuthTokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PasswordWorkflowService {
|
export class PasswordWorkflowService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -186,14 +182,8 @@ export class PasswordWorkflowService {
|
|||||||
throw new BadRequestException("Current password is incorrect");
|
throw new BadRequestException("Current password is incorrect");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// Password validation is handled by changePasswordRequestSchema (uses passwordSchema from domain)
|
||||||
newPassword.length < 8 ||
|
// No need for duplicate validation here
|
||||||
!/^(?=.*[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."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
||||||
const saltRounds =
|
const saltRounds =
|
||||||
|
|||||||
@ -21,10 +21,11 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
|
|||||||
import {
|
import {
|
||||||
signupRequestSchema,
|
signupRequestSchema,
|
||||||
type SignupRequest,
|
type SignupRequest,
|
||||||
|
type SignupResult,
|
||||||
type ValidateSignupRequest,
|
type ValidateSignupRequest,
|
||||||
type AuthTokens,
|
type AuthTokens,
|
||||||
type UserProfile,
|
|
||||||
} from "@customer-portal/domain/auth";
|
} from "@customer-portal/domain/auth";
|
||||||
|
import type { User } from "@customer-portal/domain/customer";
|
||||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||||
import type { User as PrismaUser } from "@prisma/client";
|
import type { User as PrismaUser } from "@prisma/client";
|
||||||
|
|
||||||
@ -33,11 +34,6 @@ type _SanitizedPrismaUser = Omit<
|
|||||||
"passwordHash" | "failedLoginAttempts" | "lockedUntil"
|
"passwordHash" | "failedLoginAttempts" | "lockedUntil"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface SignupResult {
|
|
||||||
user: UserProfile;
|
|
||||||
tokens: AuthTokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SignupWorkflowService {
|
export class SignupWorkflowService {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@ -12,8 +12,8 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
|||||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||||
import type { UserProfile } from "@customer-portal/domain/auth";
|
import type { User } from "@customer-portal/domain/customer";
|
||||||
import type { Customer } from "@customer-portal/domain/customer";
|
// No direct Customer import - use inferred type from WHMCS service
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WhmcsLinkWorkflowService {
|
export class WhmcsLinkWorkflowService {
|
||||||
@ -44,7 +44,7 @@ export class WhmcsLinkWorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let clientDetails: Customer;
|
let clientDetails; // Type inferred from WHMCS service
|
||||||
try {
|
try {
|
||||||
clientDetails = await this.whmcsService.getClientDetailsByEmail(email);
|
clientDetails = await this.whmcsService.getClientDetailsByEmail(email);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -103,7 +103,7 @@ export class WhmcsLinkWorkflowService {
|
|||||||
throw new UnauthorizedException("Unable to verify credentials. Please try again later.");
|
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();
|
const customerNumber = customFields["198"]?.trim() ?? customFields["Customer Number"]?.trim();
|
||||||
|
|
||||||
if (!customerNumber) {
|
if (!customerNumber) {
|
||||||
@ -136,8 +136,8 @@ export class WhmcsLinkWorkflowService {
|
|||||||
passwordHash: null,
|
passwordHash: null,
|
||||||
firstName: clientDetails.firstname || "",
|
firstName: clientDetails.firstname || "",
|
||||||
lastName: clientDetails.lastname || "",
|
lastName: clientDetails.lastname || "",
|
||||||
company: clientDetails.companyName || "",
|
company: clientDetails.companyname || "", // Raw WHMCS field name
|
||||||
phone: clientDetails.phoneNumber || clientDetails.telephoneNumber || "",
|
phone: clientDetails.phonenumberformatted || clientDetails.phonenumber || clientDetails.telephoneNumber || "", // Raw WHMCS field names
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -152,7 +152,7 @@ export class WhmcsLinkWorkflowService {
|
|||||||
throw new Error("Failed to load newly linked user");
|
throw new Error("Failed to load newly linked user");
|
||||||
}
|
}
|
||||||
|
|
||||||
const userProfile: UserProfile = mapPrismaUserToDomain(prismaUser);
|
const userProfile: User = mapPrismaUserToDomain(prismaUser);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: userProfile,
|
user: userProfile,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Injectable, UnauthorizedException } from "@nestjs/common";
|
|||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||||
import { ConfigService } from "@nestjs/config";
|
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 { UsersService } from "@bff/modules/users/users.service";
|
||||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
@ -45,7 +45,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
role: string;
|
role: string;
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
}): Promise<AuthenticatedUser> {
|
}): Promise<UserAuth> {
|
||||||
// Validate payload structure
|
// Validate payload structure
|
||||||
if (!payload.sub || !payload.email) {
|
if (!payload.sub || !payload.email) {
|
||||||
throw new Error("Invalid JWT payload");
|
throw new Error("Invalid JWT payload");
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { CacheService } from "@bff/infra/cache/cache.service";
|
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";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@ -10,13 +10,13 @@ import { PrismaService } from "@bff/infra/database/prisma.service";
|
|||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { MappingCacheService } from "./cache/mapping-cache.service";
|
import { MappingCacheService } from "./cache/mapping-cache.service";
|
||||||
import { MappingValidatorService } from "./validation/mapping-validator.service";
|
import { MappingValidatorService } from "./validation/mapping-validator.service";
|
||||||
import {
|
import type {
|
||||||
UserIdMapping,
|
UserIdMapping,
|
||||||
CreateMappingRequest,
|
CreateMappingRequest,
|
||||||
UpdateMappingRequest,
|
UpdateMappingRequest,
|
||||||
MappingSearchFilters,
|
MappingSearchFilters,
|
||||||
MappingStats,
|
MappingStats,
|
||||||
} from "./types/mapping.types";
|
} from "@customer-portal/domain/mappings";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { mapPrismaMappingToDomain } from "@bff/infra/mappers";
|
import { mapPrismaMappingToDomain } from "@bff/infra/mappers";
|
||||||
|
|
||||||
|
|||||||
@ -1,54 +1,11 @@
|
|||||||
import type {
|
import type { UserIdMapping } from "@customer-portal/domain/mappings";
|
||||||
UserIdMapping,
|
|
||||||
CreateMappingRequest,
|
|
||||||
UpdateMappingRequest,
|
|
||||||
} from "@customer-portal/domain/mappings";
|
|
||||||
|
|
||||||
// Re-export types from domain layer
|
/**
|
||||||
export type {
|
* BFF-specific mapping types
|
||||||
UserIdMapping,
|
* Business types and validation have been moved to domain layer
|
||||||
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;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Infrastructure types for caching
|
||||||
export interface MappingCacheKey {
|
export interface MappingCacheKey {
|
||||||
type: "userId" | "whmcsClientId" | "sfAccountId";
|
type: "userId" | "whmcsClientId" | "sfAccountId";
|
||||||
value: string | number;
|
value: string | number;
|
||||||
@ -59,3 +16,6 @@ export interface CachedMapping {
|
|||||||
cachedAt: Date;
|
cachedAt: Date;
|
||||||
ttl: number;
|
ttl: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export validation result from domain for backward compatibility
|
||||||
|
export type { MappingValidationResult } from "@customer-portal/domain/mappings";
|
||||||
|
|||||||
@ -1,197 +1,82 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
import {
|
||||||
createMappingRequestSchema,
|
|
||||||
updateMappingRequestSchema,
|
|
||||||
userIdMappingSchema,
|
|
||||||
type CreateMappingRequest,
|
type CreateMappingRequest,
|
||||||
type UpdateMappingRequest,
|
type UpdateMappingRequest,
|
||||||
type UserIdMapping,
|
type UserIdMapping,
|
||||||
|
type MappingValidationResult,
|
||||||
|
validateCreateRequest,
|
||||||
|
validateUpdateRequest,
|
||||||
|
validateExistingMapping,
|
||||||
|
validateBulkMappings,
|
||||||
|
validateNoConflicts,
|
||||||
|
validateDeletion,
|
||||||
|
sanitizeCreateRequest,
|
||||||
|
sanitizeUpdateRequest,
|
||||||
} from "@customer-portal/domain/mappings";
|
} from "@customer-portal/domain/mappings";
|
||||||
|
|
||||||
// Legacy interface for backward compatibility
|
/**
|
||||||
export interface MappingValidationResult {
|
* Mapping Validator Service
|
||||||
isValid: boolean;
|
*
|
||||||
errors: string[];
|
* Infrastructure service that wraps domain validation functions with logging.
|
||||||
warnings: string[];
|
* All business logic has been moved to @customer-portal/domain/mappings/validation.
|
||||||
}
|
*/
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MappingValidatorService {
|
export class MappingValidatorService {
|
||||||
constructor(@Inject(Logger) private readonly logger: Logger) {}
|
constructor(@Inject(Logger) private readonly logger: Logger) {}
|
||||||
|
|
||||||
validateCreateRequest(request: CreateMappingRequest): MappingValidationResult {
|
validateCreateRequest(request: CreateMappingRequest): MappingValidationResult {
|
||||||
const validationResult = createMappingRequestSchema.safeParse(request);
|
const result = validateCreateRequest(request);
|
||||||
|
|
||||||
if (validationResult.success) {
|
if (!result.isValid) {
|
||||||
const warnings: string[] = [];
|
this.logger.warn({ request, errors: result.errors }, "Create mapping request validation failed");
|
||||||
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 result;
|
||||||
this.logger.warn({ request, errors }, "Create mapping request validation failed");
|
|
||||||
|
|
||||||
return { isValid: false, errors, warnings: [] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
validateUpdateRequest(userId: string, request: UpdateMappingRequest): MappingValidationResult {
|
validateUpdateRequest(userId: string, request: UpdateMappingRequest): MappingValidationResult {
|
||||||
// First validate userId
|
const result = validateUpdateRequest(userId, request);
|
||||||
const userIdValidation = z.string().uuid().safeParse(userId);
|
|
||||||
if (!userIdValidation.success) {
|
if (!result.isValid) {
|
||||||
return {
|
this.logger.warn({ userId, request, errors: result.errors }, "Update mapping request validation failed");
|
||||||
isValid: false,
|
|
||||||
errors: ["User ID must be a valid UUID"],
|
|
||||||
warnings: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then validate the update request
|
return result;
|
||||||
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: [] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
validateExistingMapping(mapping: UserIdMapping): MappingValidationResult {
|
validateExistingMapping(mapping: UserIdMapping): MappingValidationResult {
|
||||||
const validationResult = userIdMappingSchema.safeParse(mapping);
|
const result = validateExistingMapping(mapping);
|
||||||
|
|
||||||
if (validationResult.success) {
|
if (!result.isValid) {
|
||||||
const warnings: string[] = [];
|
this.logger.warn({ mapping, errors: result.errors }, "Existing mapping validation failed");
|
||||||
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 result;
|
||||||
this.logger.warn({ mapping, errors }, "Existing mapping validation failed");
|
|
||||||
|
|
||||||
return { isValid: false, errors, warnings: [] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
validateBulkMappings(
|
validateBulkMappings(
|
||||||
mappings: CreateMappingRequest[]
|
mappings: CreateMappingRequest[]
|
||||||
): Array<{ index: number; validation: MappingValidationResult }> {
|
): Array<{ index: number; validation: MappingValidationResult }> {
|
||||||
return mappings.map((mapping, index) => ({
|
return validateBulkMappings(mappings);
|
||||||
index,
|
|
||||||
validation: this.validateCreateRequest(mapping),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
validateNoConflicts(
|
validateNoConflicts(
|
||||||
request: CreateMappingRequest,
|
request: CreateMappingRequest,
|
||||||
existingMappings: UserIdMapping[]
|
existingMappings: UserIdMapping[]
|
||||||
): MappingValidationResult {
|
): MappingValidationResult {
|
||||||
const errors: string[] = [];
|
return validateNoConflicts(request, existingMappings);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
validateDeletion(mapping: UserIdMapping): MappingValidationResult {
|
validateDeletion(mapping: UserIdMapping): MappingValidationResult {
|
||||||
const errors: string[] = [];
|
return validateDeletion(mapping);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
|
sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
|
||||||
// Use Zod parsing to sanitize and validate
|
return sanitizeCreateRequest(request);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest {
|
sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest {
|
||||||
const sanitized: Partial<UpdateMappingRequest> = {};
|
return sanitizeUpdateRequest(request);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,15 @@
|
|||||||
// Main orchestrator service
|
/**
|
||||||
export { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
|
* Invoice Module Exports
|
||||||
|
*/
|
||||||
|
|
||||||
// Individual services
|
export * from "./invoices.module";
|
||||||
export { InvoiceRetrievalService } from "./services/invoice-retrieval.service";
|
export * from "./invoices.controller";
|
||||||
export { InvoiceHealthService } from "./services/invoice-health.service";
|
export * from "./services/invoices-orchestrator.service";
|
||||||
|
export * from "./services/invoice-retrieval.service";
|
||||||
|
export * from "./services/invoice-health.service";
|
||||||
|
|
||||||
// Validators
|
// Export monitoring types (infrastructure concerns)
|
||||||
export { InvoiceValidatorService } from "./validators/invoice-validator.service";
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export type {
|
export type {
|
||||||
GetInvoicesOptions,
|
|
||||||
InvoiceValidationResult,
|
|
||||||
InvoiceServiceStats,
|
|
||||||
InvoiceHealthStatus,
|
InvoiceHealthStatus,
|
||||||
InvoiceStatus,
|
InvoiceServiceStats,
|
||||||
PaginationOptions,
|
} from "./types/invoice-monitoring.types";
|
||||||
UserMappingInfo,
|
|
||||||
} from "./types/invoice-service.types";
|
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
UsePipes,
|
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
|
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
|
||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.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 { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||||
|
|
||||||
import type { Invoice, InvoiceList, InvoiceSsoLink, InvoiceListQuery } from "@customer-portal/domain/billing";
|
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 { Subscription } from "@customer-portal/domain/subscriptions";
|
||||||
import type { PaymentMethodList, PaymentGatewayList, InvoicePaymentLink } from "@customer-portal/domain/payments";
|
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")
|
@Controller("invoices")
|
||||||
export class InvoicesController {
|
export class InvoicesController {
|
||||||
constructor(
|
constructor(
|
||||||
@ -31,10 +36,9 @@ export class InvoicesController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UsePipes(new ZodValidationPipe(invoiceListQuerySchema))
|
|
||||||
async getInvoices(
|
async getInvoices(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Query() query: InvoiceListQuery
|
@Query(new ZodValidationPipe(invoiceListQuerySchema)) query: InvoiceListQuery
|
||||||
): Promise<InvoiceList> {
|
): Promise<InvoiceList> {
|
||||||
return this.invoicesService.getInvoices(req.user.id, query);
|
return this.invoicesService.getInvoices(req.user.id, query);
|
||||||
}
|
}
|
||||||
@ -72,10 +76,8 @@ export class InvoicesController {
|
|||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) invoiceId: number
|
@Param("id", ParseIntPipe) invoiceId: number
|
||||||
): Promise<Invoice> {
|
): Promise<Invoice> {
|
||||||
if (invoiceId <= 0) {
|
// Validate using domain schema
|
||||||
throw new BadRequestException("Invoice ID must be a positive number");
|
invoiceSchema.shape.id.parse(invoiceId);
|
||||||
}
|
|
||||||
|
|
||||||
return this.invoicesService.getInvoiceById(req.user.id, invoiceId);
|
return this.invoicesService.getInvoiceById(req.user.id, invoiceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,10 +86,9 @@ export class InvoicesController {
|
|||||||
@Request() _req: RequestWithUser,
|
@Request() _req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) invoiceId: number
|
@Param("id", ParseIntPipe) invoiceId: number
|
||||||
): Subscription[] {
|
): Subscription[] {
|
||||||
if (invoiceId <= 0) {
|
// Validate using domain schema
|
||||||
throw new BadRequestException("Invoice ID must be a positive number");
|
invoiceSchema.shape.id.parse(invoiceId);
|
||||||
}
|
|
||||||
|
|
||||||
// This functionality has been moved to WHMCS directly
|
// This functionality has been moved to WHMCS directly
|
||||||
// For now, return empty array as subscriptions are managed in WHMCS
|
// For now, return empty array as subscriptions are managed in WHMCS
|
||||||
return [];
|
return [];
|
||||||
@ -100,9 +101,8 @@ export class InvoicesController {
|
|||||||
@Param("id", ParseIntPipe) invoiceId: number,
|
@Param("id", ParseIntPipe) invoiceId: number,
|
||||||
@Query("target") target?: "view" | "download" | "pay"
|
@Query("target") target?: "view" | "download" | "pay"
|
||||||
): Promise<InvoiceSsoLink> {
|
): Promise<InvoiceSsoLink> {
|
||||||
if (invoiceId <= 0) {
|
// Validate using domain schema
|
||||||
throw new BadRequestException("Invoice ID must be a positive number");
|
invoiceSchema.shape.id.parse(invoiceId);
|
||||||
}
|
|
||||||
|
|
||||||
// Validate target parameter
|
// Validate target parameter
|
||||||
if (target && !["view", "download", "pay"].includes(target)) {
|
if (target && !["view", "download", "pay"].includes(target)) {
|
||||||
@ -134,9 +134,8 @@ export class InvoicesController {
|
|||||||
@Query("paymentMethodId") paymentMethodId?: string,
|
@Query("paymentMethodId") paymentMethodId?: string,
|
||||||
@Query("gatewayName") gatewayName?: string
|
@Query("gatewayName") gatewayName?: string
|
||||||
): Promise<InvoicePaymentLink> {
|
): Promise<InvoicePaymentLink> {
|
||||||
if (invoiceId <= 0) {
|
// Validate using domain schema
|
||||||
throw new BadRequestException("Invoice ID must be a positive number");
|
invoiceSchema.shape.id.parse(invoiceId);
|
||||||
}
|
|
||||||
|
|
||||||
const paymentMethodIdNum = paymentMethodId ? parseInt(paymentMethodId, 10) : undefined;
|
const paymentMethodIdNum = paymentMethodId ? parseInt(paymentMethodId, 10) : undefined;
|
||||||
if (paymentMethodId && (isNaN(paymentMethodIdNum!) || paymentMethodIdNum! <= 0)) {
|
if (paymentMethodId && (isNaN(paymentMethodIdNum!) || paymentMethodIdNum! <= 0)) {
|
||||||
|
|||||||
@ -6,17 +6,20 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
|||||||
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
|
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
|
||||||
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service";
|
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service";
|
||||||
import { InvoiceHealthService } from "./services/invoice-health.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({
|
@Module({
|
||||||
imports: [WhmcsModule, MappingsModule],
|
imports: [WhmcsModule, MappingsModule],
|
||||||
controllers: [InvoicesController],
|
controllers: [InvoicesController],
|
||||||
providers: [
|
providers: [
|
||||||
// New modular services
|
|
||||||
InvoicesOrchestratorService,
|
InvoicesOrchestratorService,
|
||||||
InvoiceRetrievalService,
|
InvoiceRetrievalService,
|
||||||
InvoiceHealthService,
|
InvoiceHealthService,
|
||||||
InvoiceValidatorService,
|
|
||||||
],
|
],
|
||||||
exports: [InvoicesOrchestratorService],
|
exports: [InvoicesOrchestratorService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Logger } from "nestjs-pino";
|
|||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
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
|
* Service responsible for health checks and monitoring of invoice services
|
||||||
|
|||||||
@ -5,45 +5,49 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
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 { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { InvoiceValidatorService } from "../validators/invoice-validator.service";
|
|
||||||
import type {
|
interface UserMappingInfo {
|
||||||
GetInvoicesOptions,
|
userId: string;
|
||||||
InvoiceStatus,
|
whmcsClientId: number;
|
||||||
PaginationOptions,
|
}
|
||||||
UserMappingInfo,
|
|
||||||
} from "../types/invoice-service.types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service responsible for retrieving invoices from WHMCS
|
* 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()
|
@Injectable()
|
||||||
export class InvoiceRetrievalService {
|
export class InvoiceRetrievalService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly whmcsService: WhmcsService,
|
private readonly whmcsService: WhmcsService,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly validator: InvoiceValidatorService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get paginated invoices for a user
|
* 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> {
|
async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise<InvoiceList> {
|
||||||
const { page = 1, limit = 10, status } = options;
|
// Validate options against schema for internal calls
|
||||||
|
const validatedOptions = invoiceListQuerySchema.parse(options);
|
||||||
|
const { page = 1, limit = 10, status } = validatedOptions;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate inputs
|
|
||||||
this.validator.validateUserId(userId);
|
|
||||||
this.validator.validatePagination({ page, limit });
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
this.validator.validateInvoiceStatus(status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user mapping
|
// Get user mapping
|
||||||
const mapping = await this.getUserMapping(userId);
|
const mapping = await this.getUserMapping(userId);
|
||||||
|
|
||||||
@ -78,12 +82,13 @@ export class InvoiceRetrievalService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get individual invoice by ID
|
* 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> {
|
async getInvoiceById(userId: string, invoiceId: number): Promise<Invoice> {
|
||||||
try {
|
try {
|
||||||
// Validate inputs
|
// Validate invoice ID using schema
|
||||||
this.validator.validateUserId(userId);
|
invoiceSchema.shape.id.parse(invoiceId);
|
||||||
this.validator.validateInvoiceId(invoiceId);
|
|
||||||
|
|
||||||
// Get user mapping
|
// Get user mapping
|
||||||
const mapping = await this.getUserMapping(userId);
|
const mapping = await this.getUserMapping(userId);
|
||||||
@ -116,17 +121,14 @@ export class InvoiceRetrievalService {
|
|||||||
async getInvoicesByStatus(
|
async getInvoicesByStatus(
|
||||||
userId: string,
|
userId: string,
|
||||||
status: InvoiceStatus,
|
status: InvoiceStatus,
|
||||||
options: PaginationOptions = {}
|
options: Partial<InvoiceListQuery> = {}
|
||||||
): Promise<InvoiceList> {
|
): Promise<InvoiceList> {
|
||||||
const { page = 1, limit = 10 } = options;
|
const { page = 1, limit = 10 } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate inputs
|
// Cast to the subset of statuses supported by InvoiceListQuery
|
||||||
this.validator.validateUserId(userId);
|
const queryStatus = status as "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
|
||||||
this.validator.validateInvoiceStatus(status);
|
return await this.getInvoices(userId, { page, limit, status: queryStatus });
|
||||||
this.validator.validatePagination({ page, limit });
|
|
||||||
|
|
||||||
return await this.getInvoices(userId, { page, limit, status });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to get ${status} invoices for user ${userId}`, {
|
this.logger.error(`Failed to get ${status} invoices for user ${userId}`, {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
@ -144,21 +146,21 @@ export class InvoiceRetrievalService {
|
|||||||
/**
|
/**
|
||||||
* Get unpaid invoices for a user
|
* 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);
|
return this.getInvoicesByStatus(userId, "Unpaid", options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get overdue invoices for a user
|
* 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);
|
return this.getInvoicesByStatus(userId, "Overdue", options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get paid invoices for a user
|
* 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);
|
return this.getInvoicesByStatus(userId, "Paid", options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,7 +169,7 @@ export class InvoiceRetrievalService {
|
|||||||
*/
|
*/
|
||||||
async getCancelledInvoices(
|
async getCancelledInvoices(
|
||||||
userId: string,
|
userId: string,
|
||||||
options: PaginationOptions = {}
|
options: Partial<InvoiceListQuery> = {}
|
||||||
): Promise<InvoiceList> {
|
): Promise<InvoiceList> {
|
||||||
return this.getInvoicesByStatus(userId, "Cancelled", options);
|
return this.getInvoicesByStatus(userId, "Cancelled", options);
|
||||||
}
|
}
|
||||||
@ -177,7 +179,7 @@ export class InvoiceRetrievalService {
|
|||||||
*/
|
*/
|
||||||
async getCollectionsInvoices(
|
async getCollectionsInvoices(
|
||||||
userId: string,
|
userId: string,
|
||||||
options: PaginationOptions = {}
|
options: Partial<InvoiceListQuery> = {}
|
||||||
): Promise<InvoiceList> {
|
): Promise<InvoiceList> {
|
||||||
return this.getInvoicesByStatus(userId, "Collections", options);
|
return this.getInvoicesByStatus(userId, "Collections", options);
|
||||||
}
|
}
|
||||||
@ -186,13 +188,19 @@ export class InvoiceRetrievalService {
|
|||||||
* Get user mapping with validation
|
* Get user mapping with validation
|
||||||
*/
|
*/
|
||||||
private async getUserMapping(userId: string): Promise<UserMappingInfo> {
|
private async getUserMapping(userId: string): Promise<UserMappingInfo> {
|
||||||
|
// Validate userId is a valid UUID
|
||||||
|
validateUuidV4OrThrow(userId);
|
||||||
|
|
||||||
const mapping = await this.mappingsService.findByUserId(userId);
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
|
|
||||||
if (!mapping?.whmcsClientId) {
|
if (!mapping?.whmcsClientId) {
|
||||||
throw new NotFoundException("WHMCS client mapping not found");
|
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 {
|
return {
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@ -1,16 +1,19 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
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 { InvoiceRetrievalService } from "./invoice-retrieval.service";
|
||||||
import { InvoiceHealthService } from "./invoice-health.service";
|
import { InvoiceHealthService } from "./invoice-health.service";
|
||||||
import { InvoiceValidatorService } from "../validators/invoice-validator.service";
|
|
||||||
import type {
|
import type {
|
||||||
GetInvoicesOptions,
|
|
||||||
InvoiceStatus,
|
|
||||||
PaginationOptions,
|
|
||||||
InvoiceHealthStatus,
|
InvoiceHealthStatus,
|
||||||
InvoiceServiceStats,
|
InvoiceServiceStats,
|
||||||
} from "../types/invoice-service.types";
|
} from "../types/invoice-monitoring.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main orchestrator service for invoice operations
|
* Main orchestrator service for invoice operations
|
||||||
@ -21,7 +24,6 @@ export class InvoicesOrchestratorService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly retrievalService: InvoiceRetrievalService,
|
private readonly retrievalService: InvoiceRetrievalService,
|
||||||
private readonly healthService: InvoiceHealthService,
|
private readonly healthService: InvoiceHealthService,
|
||||||
private readonly validator: InvoiceValidatorService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -32,7 +34,7 @@ export class InvoicesOrchestratorService {
|
|||||||
/**
|
/**
|
||||||
* Get paginated invoices for a user
|
* 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();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -67,7 +69,7 @@ export class InvoicesOrchestratorService {
|
|||||||
async getInvoicesByStatus(
|
async getInvoicesByStatus(
|
||||||
userId: string,
|
userId: string,
|
||||||
status: InvoiceStatus,
|
status: InvoiceStatus,
|
||||||
options: PaginationOptions = {}
|
options: Partial<InvoiceListQuery> = {}
|
||||||
): Promise<InvoiceList> {
|
): Promise<InvoiceList> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
@ -84,21 +86,21 @@ export class InvoicesOrchestratorService {
|
|||||||
/**
|
/**
|
||||||
* Get unpaid invoices for a user
|
* 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);
|
return this.retrievalService.getUnpaidInvoices(userId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get overdue invoices for a user
|
* 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);
|
return this.retrievalService.getOverdueInvoices(userId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get paid invoices for a user
|
* 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);
|
return this.retrievalService.getPaidInvoices(userId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +109,7 @@ export class InvoicesOrchestratorService {
|
|||||||
*/
|
*/
|
||||||
async getCancelledInvoices(
|
async getCancelledInvoices(
|
||||||
userId: string,
|
userId: string,
|
||||||
options: PaginationOptions = {}
|
options: Partial<InvoiceListQuery> = {}
|
||||||
): Promise<InvoiceList> {
|
): Promise<InvoiceList> {
|
||||||
return this.retrievalService.getCancelledInvoices(userId, options);
|
return this.retrievalService.getCancelledInvoices(userId, options);
|
||||||
}
|
}
|
||||||
@ -117,7 +119,7 @@ export class InvoicesOrchestratorService {
|
|||||||
*/
|
*/
|
||||||
async getCollectionsInvoices(
|
async getCollectionsInvoices(
|
||||||
userId: string,
|
userId: string,
|
||||||
options: PaginationOptions = {}
|
options: Partial<InvoiceListQuery> = {}
|
||||||
): Promise<InvoiceList> {
|
): Promise<InvoiceList> {
|
||||||
return this.retrievalService.getCollectionsInvoices(userId, options);
|
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) {
|
getValidStatuses(): readonly InvoiceStatus[] {
|
||||||
return this.validator.validateGetInvoicesOptions(options);
|
return VALID_INVOICE_STATUSES;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get valid invoice statuses
|
* Get pagination limits (from domain)
|
||||||
*/
|
*/
|
||||||
getValidStatuses() {
|
getPaginationLimits(): { min: number; max: number } {
|
||||||
return this.validator.getValidStatuses();
|
return {
|
||||||
}
|
min: INVOICE_PAGINATION.MIN_LIMIT,
|
||||||
|
max: INVOICE_PAGINATION.MAX_LIMIT,
|
||||||
/**
|
};
|
||||||
* Get pagination limits
|
|
||||||
*/
|
|
||||||
getPaginationLimits() {
|
|
||||||
return this.validator.getPaginationLimits();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -21,7 +21,6 @@ import {
|
|||||||
type SalesforceOrderItemRecord,
|
type SalesforceOrderItemRecord,
|
||||||
Providers as OrderProviders,
|
Providers as OrderProviders,
|
||||||
} from "@customer-portal/domain/orders";
|
} from "@customer-portal/domain/orders";
|
||||||
import type { FulfillmentOrderDetails, FulfillmentOrderItem } from "../types/fulfillment.types";
|
|
||||||
|
|
||||||
export interface OrderItemMappingResult {
|
export interface OrderItemMappingResult {
|
||||||
whmcsItems: any[];
|
whmcsItems: any[];
|
||||||
@ -44,7 +43,7 @@ export interface OrderFulfillmentContext {
|
|||||||
sfOrderId: string;
|
sfOrderId: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
validation: OrderFulfillmentValidationResult | null;
|
validation: OrderFulfillmentValidationResult | null;
|
||||||
orderDetails?: FulfillmentOrderDetails;
|
orderDetails?: OrderDetails;
|
||||||
mappingResult?: OrderItemMappingResult;
|
mappingResult?: OrderItemMappingResult;
|
||||||
whmcsResult?: WhmcsOrderResult;
|
whmcsResult?: WhmcsOrderResult;
|
||||||
steps: OrderFulfillmentStep[];
|
steps: OrderFulfillmentStep[];
|
||||||
@ -125,7 +124,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
if (!orderDetails) {
|
if (!orderDetails) {
|
||||||
throw new Error("Order details could not be retrieved.");
|
throw new Error("Order details could not be retrieved.");
|
||||||
}
|
}
|
||||||
context.orderDetails = this.mapOrderDetails(orderDetails);
|
context.orderDetails = orderDetails;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to get order details", {
|
this.logger.error("Failed to get order details", {
|
||||||
sfOrderId,
|
sfOrderId,
|
||||||
@ -175,7 +174,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
return Promise.reject(new Error("Order details are required for mapping"));
|
return Promise.reject(new Error("Order details are required for mapping"));
|
||||||
}
|
}
|
||||||
// Use domain mapper directly - single transformation!
|
// Use domain mapper directly - single transformation!
|
||||||
const result = OrderProviders.Whmcs.mapFulfillmentOrderItems(context.orderDetails.items);
|
const result = OrderProviders.Whmcs.mapOrderToWhmcsItems(context.orderDetails);
|
||||||
mappingResult = result;
|
mappingResult = result;
|
||||||
|
|
||||||
this.logger.log("OrderItems mapped to WHMCS", {
|
this.logger.log("OrderItems mapped to WHMCS", {
|
||||||
@ -199,7 +198,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
throw new Error("Mapping result is not available");
|
throw new Error("Mapping result is not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderNotes = this.orderWhmcsMapper.createOrderNotes(
|
const orderNotes = OrderProviders.Whmcs.createOrderNotes(
|
||||||
sfOrderId,
|
sfOrderId,
|
||||||
`Provisioned from Salesforce Order ${sfOrderId}`
|
`Provisioned from Salesforce Order ${sfOrderId}`
|
||||||
);
|
);
|
||||||
@ -338,185 +337,6 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
return context;
|
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
|
* Initialize fulfillment steps
|
||||||
@ -540,76 +360,6 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
return steps;
|
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> {
|
private extractConfigurations(value: unknown): Record<string, unknown> {
|
||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
@ -618,64 +368,6 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
return {};
|
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
|
* Handle fulfillment errors and update Salesforce
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Injectable, BadRequestException, ConflictException, Inject } from "@nestjs/common";
|
import { Injectable, BadRequestException, ConflictException, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { z } from "zod";
|
||||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||||
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service";
|
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.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";
|
import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
|
||||||
type OrderStringFieldKey = "activationStatus";
|
type OrderStringFieldKey = "activationStatus";
|
||||||
|
|
||||||
|
// Schema for validating Salesforce Account ID
|
||||||
|
const salesforceAccountIdSchema = z.string().min(1, "Salesforce AccountId is required");
|
||||||
|
|
||||||
export interface OrderFulfillmentValidationResult {
|
export interface OrderFulfillmentValidationResult {
|
||||||
sfOrder: SalesforceOrderRecord;
|
sfOrder: SalesforceOrderRecord;
|
||||||
clientId: number;
|
clientId: number;
|
||||||
@ -65,10 +69,8 @@ export class OrderFulfillmentValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Get WHMCS client mapping
|
// 3. Get WHMCS client mapping
|
||||||
const accountId = sfOrder.AccountId;
|
// Validate AccountId using schema instead of manual type checks
|
||||||
if (typeof accountId !== "string" || accountId.length === 0) {
|
const accountId = salesforceAccountIdSchema.parse(sfOrder.AccountId);
|
||||||
throw new BadRequestException("Salesforce order is missing AccountId");
|
|
||||||
}
|
|
||||||
const mapping = await this.mappingsService.findBySfAccountId(accountId);
|
const mapping = await this.mappingsService.findBySfAccountId(accountId);
|
||||||
if (!mapping?.whmcsClientId) {
|
if (!mapping?.whmcsClientId) {
|
||||||
throw new BadRequestException(`No WHMCS client mapping found for account ${accountId}`);
|
throw new BadRequestException(`No WHMCS client mapping found for account ${accountId}`);
|
||||||
|
|||||||
@ -16,7 +16,9 @@ import {
|
|||||||
hasVpnActivationFee,
|
hasVpnActivationFee,
|
||||||
hasInternetServicePlan,
|
hasInternetServicePlan,
|
||||||
} from "@customer-portal/domain/orders";
|
} 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";
|
import { OrderPricebookService } from "./order-pricebook.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
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";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
|
||||||
export interface SimFulfillmentRequest {
|
export interface SimFulfillmentRequest {
|
||||||
orderDetails: FulfillmentOrderDetails;
|
orderDetails: OrderDetails;
|
||||||
configurations: Record<string, unknown>;
|
configurations: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ export class SimFulfillmentService {
|
|||||||
const mnp = this.extractMnpConfig(configurations);
|
const mnp = this.extractMnpConfig(configurations);
|
||||||
|
|
||||||
const simPlanItem = orderDetails.items.find(
|
const simPlanItem = orderDetails.items.find(
|
||||||
(item: FulfillmentOrderItem) =>
|
(item: OrderItemDetails) =>
|
||||||
item.product?.itemClass === "Plan" || item.product?.sku?.toLowerCase().includes("sim")
|
item.product?.itemClass === "Plan" || item.product?.sku?.toLowerCase().includes("sim")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
export type {
|
|
||||||
FulfillmentOrderProduct,
|
|
||||||
FulfillmentOrderItem,
|
|
||||||
FulfillmentOrderDetails,
|
|
||||||
} from "@customer-portal/domain/orders";
|
|
||||||
|
|
||||||
export {
|
|
||||||
fulfillmentOrderProductSchema,
|
|
||||||
fulfillmentOrderItemSchema,
|
|
||||||
fulfillmentOrderDetailsSchema,
|
|
||||||
} from "@customer-portal/domain/orders";
|
|
||||||
@ -8,7 +8,7 @@ import type {
|
|||||||
SimCancelRequest,
|
SimCancelRequest,
|
||||||
SimTopUpHistoryRequest,
|
SimTopUpHistoryRequest,
|
||||||
SimFeaturesUpdateRequest,
|
SimFeaturesUpdateRequest,
|
||||||
} from "./sim-management/types/sim-requests.types";
|
} from "@customer-portal/domain/sim";
|
||||||
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
|
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@ -9,14 +9,14 @@ export { EsimManagementService } from "./services/esim-management.service";
|
|||||||
export { SimValidationService } from "./services/sim-validation.service";
|
export { SimValidationService } from "./services/sim-validation.service";
|
||||||
export { SimNotificationService } from "./services/sim-notification.service";
|
export { SimNotificationService } from "./services/sim-notification.service";
|
||||||
|
|
||||||
// Types
|
// Types (re-export from domain for module convenience)
|
||||||
export type {
|
export type {
|
||||||
SimTopUpRequest,
|
SimTopUpRequest,
|
||||||
SimPlanChangeRequest,
|
SimPlanChangeRequest,
|
||||||
SimCancelRequest,
|
SimCancelRequest,
|
||||||
SimTopUpHistoryRequest,
|
SimTopUpHistoryRequest,
|
||||||
SimFeaturesUpdateRequest,
|
SimFeaturesUpdateRequest,
|
||||||
} from "./types/sim-requests.types";
|
} from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
// Interfaces
|
// Interfaces
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f
|
|||||||
import { SimValidationService } from "./sim-validation.service";
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
import { SimNotificationService } from "./sim-notification.service";
|
import { SimNotificationService } from "./sim-notification.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
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()
|
@Injectable()
|
||||||
export class SimCancellationService {
|
export class SimCancellationService {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import type {
|
|||||||
SimCancelRequest,
|
SimCancelRequest,
|
||||||
SimTopUpHistoryRequest,
|
SimTopUpHistoryRequest,
|
||||||
SimFeaturesUpdateRequest,
|
SimFeaturesUpdateRequest,
|
||||||
} from "../types/sim-requests.types";
|
} from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimOrchestratorService {
|
export class SimOrchestratorService {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f
|
|||||||
import { SimValidationService } from "./sim-validation.service";
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
import { SimNotificationService } from "./sim-notification.service";
|
import { SimNotificationService } from "./sim-notification.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
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()
|
@Injectable()
|
||||||
export class SimPlanService {
|
export class SimPlanService {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
|||||||
import { SimValidationService } from "./sim-validation.service";
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
import { SimNotificationService } from "./sim-notification.service";
|
import { SimNotificationService } from "./sim-notification.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
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()
|
@Injectable()
|
||||||
export class SimTopUpService {
|
export class SimTopUpService {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { SimValidationService } from "./sim-validation.service";
|
|||||||
import { SimUsageStoreService } from "../../sim-usage-store.service";
|
import { SimUsageStoreService } from "../../sim-usage-store.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type { SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
|
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";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@ -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";
|
|
||||||
@ -16,6 +16,9 @@ import { SimManagementService } from "./sim-management.service";
|
|||||||
import {
|
import {
|
||||||
Subscription,
|
Subscription,
|
||||||
SubscriptionList,
|
SubscriptionList,
|
||||||
|
SubscriptionStats,
|
||||||
|
SimActionResponse,
|
||||||
|
SimPlanChangeResult,
|
||||||
subscriptionQuerySchema,
|
subscriptionQuerySchema,
|
||||||
type SubscriptionQuery,
|
type SubscriptionQuery,
|
||||||
} from "@customer-portal/domain/subscriptions";
|
} from "@customer-portal/domain/subscriptions";
|
||||||
@ -63,12 +66,7 @@ export class SubscriptionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{
|
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> {
|
||||||
total: number;
|
|
||||||
active: number;
|
|
||||||
completed: number;
|
|
||||||
cancelled: number;
|
|
||||||
}> {
|
|
||||||
return this.subscriptionsService.getSubscriptionStats(req.user.id);
|
return this.subscriptionsService.getSubscriptionStats(req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,7 +153,7 @@ export class SubscriptionsController {
|
|||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Body() body: SimTopupRequest
|
@Body() body: SimTopupRequest
|
||||||
) {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.topUpSim(req.user.id, subscriptionId, body);
|
await this.simManagementService.topUpSim(req.user.id, subscriptionId, body);
|
||||||
return { success: true, message: "SIM top-up completed successfully" };
|
return { success: true, message: "SIM top-up completed successfully" };
|
||||||
}
|
}
|
||||||
@ -166,7 +164,7 @@ export class SubscriptionsController {
|
|||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Body() body: SimChangePlanRequest
|
@Body() body: SimChangePlanRequest
|
||||||
) {
|
): Promise<SimPlanChangeResult> {
|
||||||
const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body);
|
const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -181,7 +179,7 @@ export class SubscriptionsController {
|
|||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Body() body: SimCancelRequest
|
@Body() body: SimCancelRequest
|
||||||
) {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.cancelSim(req.user.id, subscriptionId, body);
|
await this.simManagementService.cancelSim(req.user.id, subscriptionId, body);
|
||||||
return { success: true, message: "SIM cancellation completed successfully" };
|
return { success: true, message: "SIM cancellation completed successfully" };
|
||||||
}
|
}
|
||||||
@ -191,7 +189,7 @@ export class SubscriptionsController {
|
|||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Body() body: { newEid?: string } = {}
|
@Body() body: { newEid?: string } = {}
|
||||||
) {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body.newEid);
|
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body.newEid);
|
||||||
return { success: true, message: "eSIM profile reissue completed successfully" };
|
return { success: true, message: "eSIM profile reissue completed successfully" };
|
||||||
}
|
}
|
||||||
@ -202,7 +200,7 @@ export class SubscriptionsController {
|
|||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Body() body: SimFeaturesRequest
|
@Body() body: SimFeaturesRequest
|
||||||
) {
|
): Promise<SimActionResponse> {
|
||||||
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
||||||
return { success: true, message: "SIM features updated successfully" };
|
return { success: true, message: "SIM features updated successfully" };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,9 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { subscriptionSchema } from "@customer-portal/domain/subscriptions";
|
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 {
|
export interface GetSubscriptionsOptions {
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|||||||
@ -2,13 +2,13 @@ import { Injectable, Inject, NotFoundException, BadRequestException } from "@nes
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import type { User as PrismaUser } from "@prisma/client";
|
import type { User as PrismaUser } from "@prisma/client";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
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 { PrismaService } from "@bff/infra/database/prisma.service";
|
||||||
import {
|
import {
|
||||||
updateCustomerProfileRequestSchema,
|
updateCustomerProfileRequestSchema,
|
||||||
type AuthenticatedUser,
|
|
||||||
type UpdateCustomerProfileRequest,
|
type UpdateCustomerProfileRequest,
|
||||||
} from "@customer-portal/domain/auth";
|
} 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 { Subscription } from "@customer-portal/domain/subscriptions";
|
||||||
import type { Invoice } from "@customer-portal/domain/billing";
|
import type { Invoice } from "@customer-portal/domain/billing";
|
||||||
import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard";
|
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
|
* 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);
|
const validEmail = this.validateEmail(email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -101,7 +101,7 @@ export class UsersService {
|
|||||||
/**
|
/**
|
||||||
* Get user profile - primary method for fetching authenticated user with full WHMCS data
|
* 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);
|
const validId = this.validateUserId(id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -123,7 +123,7 @@ export class UsersService {
|
|||||||
* Get complete customer profile from WHMCS (single source of truth)
|
* Get complete customer profile from WHMCS (single source of truth)
|
||||||
* Includes profile fields + address + auth state
|
* 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 } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) throw new NotFoundException("User not found");
|
if (!user) throw new NotFoundException("User not found");
|
||||||
|
|
||||||
@ -133,35 +133,14 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch complete client data from WHMCS (source of truth)
|
// Get WHMCS client data (source of truth for profile)
|
||||||
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||||
|
|
||||||
// Map WHMCS client to CustomerProfile with auth state
|
// Map Prisma user to UserAuth
|
||||||
const profile: AuthenticatedUser = {
|
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user);
|
||||||
id: user.id,
|
|
||||||
email: client.email,
|
// Domain combines UserAuth + WhmcsClient → User
|
||||||
firstname: client.firstname || null,
|
return combineToUser(userAuth, whmcsClient as CustomerProviders.Whmcs.WhmcsClient);
|
||||||
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;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to fetch client profile from WHMCS", {
|
this.logger.error("Failed to fetch client profile from WHMCS", {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
@ -175,7 +154,7 @@ export class UsersService {
|
|||||||
/**
|
/**
|
||||||
* Create user (auth state only in portal DB)
|
* 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!);
|
const validEmail = this.validateEmail(userData.email!);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -198,7 +177,7 @@ export class UsersService {
|
|||||||
* Update user auth state (password, login attempts, etc.)
|
* Update user auth state (password, login attempts, etc.)
|
||||||
* For profile updates, use updateProfile instead
|
* 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 validId = this.validateUserId(id);
|
||||||
const sanitizedData = this.sanitizeUserData(userData);
|
const sanitizedData = this.sanitizeUserData(userData);
|
||||||
|
|
||||||
@ -222,7 +201,7 @@ export class UsersService {
|
|||||||
* Update customer profile in WHMCS (single source of truth)
|
* Update customer profile in WHMCS (single source of truth)
|
||||||
* Can update profile fields AND/OR address fields in one call
|
* 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 validId = this.validateUserId(userId);
|
||||||
const parsed = updateCustomerProfileRequestSchema.parse(update);
|
const parsed = updateCustomerProfileRequestSchema.parse(update);
|
||||||
|
|
||||||
@ -466,7 +445,12 @@ export class UsersService {
|
|||||||
let currency = "JPY"; // Default
|
let currency = "JPY"; // Default
|
||||||
try {
|
try {
|
||||||
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
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) {
|
} catch (error) {
|
||||||
this.logger.warn("Could not fetch currency from WHMCS client", { userId });
|
this.logger.warn("Could not fetch currency from WHMCS client", { userId });
|
||||||
}
|
}
|
||||||
|
|||||||
137
bff-validation-migration.md
Normal file
137
bff-validation-migration.md
Normal 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
413
docs/VALIDATION_PATTERNS.md
Normal 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.
|
||||||
|
|
||||||
@ -1,58 +1,51 @@
|
|||||||
/**
|
/**
|
||||||
* Auth Domain - Contract
|
* Auth Domain - Contract
|
||||||
*
|
*
|
||||||
* Canonical authentication types shared across applications.
|
* Constants and types for the authentication domain.
|
||||||
* Most types are derived from schemas (see schema.ts).
|
* 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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
export const TOKEN_TYPE = {
|
||||||
* AuthenticatedUser - Complete user profile with authentication state
|
BEARER: "Bearer",
|
||||||
* Extends CustomerProfile (from WHMCS) with auth-specific fields from portal DB
|
} as const;
|
||||||
* Follows WHMCS client field naming (firstname, lastname, etc.)
|
|
||||||
*/
|
|
||||||
export interface AuthenticatedUser extends CustomerProfile {
|
|
||||||
role: UserRole;
|
|
||||||
emailVerified: boolean;
|
|
||||||
mfaEnabled: boolean;
|
|
||||||
lastLoginAt?: IsoDateTimeString;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export type TokenTypeValue = (typeof TOKEN_TYPE)[keyof typeof TOKEN_TYPE];
|
||||||
* User profile type alias
|
|
||||||
*/
|
|
||||||
export type UserProfile = AuthenticatedUser;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Auth Error (Business Type)
|
// Gender Constants
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface AuthError {
|
export const GENDER = {
|
||||||
code:
|
MALE: "male",
|
||||||
| "INVALID_CREDENTIALS"
|
FEMALE: "female",
|
||||||
| "USER_NOT_FOUND"
|
OTHER: "other",
|
||||||
| "EMAIL_ALREADY_EXISTS"
|
} as const;
|
||||||
| "EMAIL_NOT_VERIFIED"
|
|
||||||
| "INVALID_TOKEN"
|
export type GenderValue = (typeof GENDER)[keyof typeof GENDER];
|
||||||
| "TOKEN_EXPIRED"
|
|
||||||
| "ACCOUNT_LOCKED"
|
|
||||||
| "RATE_LIMITED"
|
|
||||||
| "NETWORK_ERROR";
|
|
||||||
message: string;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Re-export Types from Schema (Schema-First Approach)
|
// Re-export Types from Schema (Schema-First Approach)
|
||||||
@ -73,10 +66,14 @@ export type {
|
|||||||
SsoLinkRequest,
|
SsoLinkRequest,
|
||||||
CheckPasswordNeededRequest,
|
CheckPasswordNeededRequest,
|
||||||
RefreshTokenRequest,
|
RefreshTokenRequest,
|
||||||
// Response types
|
// Token types
|
||||||
AuthTokens,
|
AuthTokens,
|
||||||
|
// Response types
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
|
SignupResult,
|
||||||
|
PasswordChangeResult,
|
||||||
|
SsoLinkResponse,
|
||||||
|
CheckPasswordNeededResponse,
|
||||||
|
// Error types
|
||||||
|
AuthError,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
// Re-export from customer for convenience
|
|
||||||
export type { Activity } from "../dashboard/contract";
|
|
||||||
|
|||||||
@ -1,18 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Auth Domain
|
* 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 {
|
||||||
export * from "./schema";
|
AUTH_ERROR_CODE,
|
||||||
|
TOKEN_TYPE,
|
||||||
|
GENDER,
|
||||||
|
type AuthErrorCode,
|
||||||
|
type TokenTypeValue,
|
||||||
|
type GenderValue,
|
||||||
|
} from "./contract";
|
||||||
|
|
||||||
// Re-export types for convenience
|
|
||||||
export type {
|
export type {
|
||||||
// Request types
|
// Request types
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
@ -28,9 +37,47 @@ export type {
|
|||||||
SsoLinkRequest,
|
SsoLinkRequest,
|
||||||
CheckPasswordNeededRequest,
|
CheckPasswordNeededRequest,
|
||||||
RefreshTokenRequest,
|
RefreshTokenRequest,
|
||||||
// Response types
|
// Token types
|
||||||
AuthTokens,
|
AuthTokens,
|
||||||
|
// Response types
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
// Re-exported
|
SignupResult,
|
||||||
Activity,
|
PasswordChangeResult,
|
||||||
} from './contract';
|
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";
|
||||||
|
|||||||
@ -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 { z } from "zod";
|
||||||
|
|
||||||
import { emailSchema, nameSchema, passwordSchema, phoneSchema } from "../common/schema";
|
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"]);
|
const genderEnum = z.enum(["male", "female", "other"]);
|
||||||
|
|
||||||
@ -108,6 +120,10 @@ export const refreshTokenRequestSchema = z.object({
|
|||||||
deviceId: z.string().optional(),
|
deviceId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Token Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export const authTokensSchema = z.object({
|
export const authTokensSchema = z.object({
|
||||||
accessToken: z.string().min(1, "Access token is required"),
|
accessToken: z.string().min(1, "Access token is required"),
|
||||||
refreshToken: z.string().min(1, "Refresh 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"),
|
tokenType: z.literal("Bearer"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Authentication Response Schemas (Reference User from Customer Domain)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth response - returns User from customer domain
|
||||||
|
*/
|
||||||
export const authResponseSchema = z.object({
|
export const authResponseSchema = z.object({
|
||||||
user: z.unknown(),
|
user: userSchema, // User from customer domain
|
||||||
tokens: authTokensSchema,
|
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
|
// Request types
|
||||||
@ -140,6 +196,23 @@ export type SsoLinkRequest = z.infer<typeof ssoLinkRequestSchema>;
|
|||||||
export type CheckPasswordNeededRequest = z.infer<typeof checkPasswordNeededRequestSchema>;
|
export type CheckPasswordNeededRequest = z.infer<typeof checkPasswordNeededRequestSchema>;
|
||||||
export type RefreshTokenRequest = z.infer<typeof refreshTokenRequestSchema>;
|
export type RefreshTokenRequest = z.infer<typeof refreshTokenRequestSchema>;
|
||||||
|
|
||||||
// Response types
|
// Token types
|
||||||
export type AuthTokens = z.infer<typeof authTokensSchema>;
|
export type AuthTokens = z.infer<typeof authTokensSchema>;
|
||||||
|
|
||||||
|
// Response types
|
||||||
export type AuthResponse = z.infer<typeof authResponseSchema>;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,10 +30,14 @@ export type {
|
|||||||
// Provider adapters
|
// Provider adapters
|
||||||
export * as Providers from "./providers";
|
export * as Providers from "./providers";
|
||||||
|
|
||||||
// Re-export provider raw types and response types
|
// Re-export provider raw types (request and response)
|
||||||
export * from "./providers/whmcs/raw.types";
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
// Request params
|
||||||
|
WhmcsGetInvoicesParams,
|
||||||
|
WhmcsCreateInvoiceParams,
|
||||||
|
WhmcsUpdateInvoiceParams,
|
||||||
|
WhmcsCapturePaymentParams,
|
||||||
|
// Response types
|
||||||
WhmcsInvoiceListResponse,
|
WhmcsInvoiceListResponse,
|
||||||
WhmcsInvoiceResponse,
|
WhmcsInvoiceResponse,
|
||||||
WhmcsCreateInvoiceResponse,
|
WhmcsCreateInvoiceResponse,
|
||||||
|
|||||||
@ -1,12 +1,96 @@
|
|||||||
/**
|
/**
|
||||||
* WHMCS Billing Provider - Raw Types
|
* WHMCS Billing Provider - Raw Types
|
||||||
*
|
*
|
||||||
* Type definitions for raw WHMCS API responses related to billing.
|
* Type definitions for the WHMCS billing API contract:
|
||||||
* These types represent the actual structure returned by WHMCS APIs.
|
* - Request parameter types (API inputs)
|
||||||
|
* - Response types (API outputs)
|
||||||
|
*
|
||||||
|
* These represent the exact structure used by WHMCS APIs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
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
|
// Raw WHMCS Invoice Item
|
||||||
export const whmcsInvoiceItemRawSchema = z.object({
|
export const whmcsInvoiceItemRawSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
|
export * from "./validation";
|
||||||
|
|
||||||
// Common provider types (generic wrappers used across domains)
|
// Common provider types (generic wrappers used across domains)
|
||||||
export * as CommonProviders from "./providers";
|
export * as CommonProviders from "./providers";
|
||||||
|
|||||||
98
packages/domain/common/validation.ts
Normal file
98
packages/domain/common/validation.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,45 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* Customer Domain - Contract
|
* Customer Domain - Contract
|
||||||
*
|
*
|
||||||
* Business types and provider-specific mapping types.
|
* Constants and provider-specific types.
|
||||||
* Validated types are derived from schemas (see schema.ts).
|
* 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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
export const USER_ROLE = {
|
||||||
* CustomerProfile - Core profile data following WHMCS client structure
|
USER: "USER",
|
||||||
* Used as the base for authenticated users in the portal
|
ADMIN: "ADMIN",
|
||||||
*/
|
} as const;
|
||||||
export interface CustomerProfile {
|
|
||||||
id: string;
|
export type UserRoleValue = (typeof USER_ROLE)[keyof typeof USER_ROLE];
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Salesforce Integration Types (Provider-Specific, Not Validated)
|
// Salesforce Integration Types (Provider-Specific, Not Validated)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salesforce account field mapping
|
||||||
|
* This is provider-specific and not validated at runtime
|
||||||
|
*/
|
||||||
export interface SalesforceAccountFieldMap {
|
export interface SalesforceAccountFieldMap {
|
||||||
internetEligibility: string;
|
internetEligibility: string;
|
||||||
customerNumber: string;
|
customerNumber: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salesforce account record structure
|
||||||
|
* Raw structure from Salesforce API
|
||||||
|
*/
|
||||||
export interface SalesforceAccountRecord {
|
export interface SalesforceAccountRecord {
|
||||||
Id: string;
|
Id: string;
|
||||||
Name?: string | null;
|
Name?: string | null;
|
||||||
@ -52,14 +47,9 @@ export interface SalesforceAccountRecord {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CustomerAddress,
|
User,
|
||||||
|
UserAuth,
|
||||||
|
UserRole,
|
||||||
Address,
|
Address,
|
||||||
CustomerEmailPreferences,
|
|
||||||
CustomerUser,
|
|
||||||
CustomerStats,
|
|
||||||
Customer,
|
|
||||||
AddressFormData,
|
AddressFormData,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
// Re-export helper function
|
|
||||||
export { addressFormToRequest } from './schema';
|
|
||||||
|
|||||||
@ -1,43 +1,92 @@
|
|||||||
/**
|
/**
|
||||||
* Customer Domain
|
* 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)
|
* 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 {
|
export type {
|
||||||
SalesforceAccountFieldMap,
|
User, // API response type (normalized camelCase)
|
||||||
SalesforceAccountRecord,
|
UserAuth, // Portal DB auth state
|
||||||
} from "./contract";
|
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 {
|
||||||
export type {
|
userSchema,
|
||||||
CustomerAddress,
|
userAuthSchema,
|
||||||
Address,
|
addressSchema,
|
||||||
CustomerEmailPreferences,
|
addressFormSchema,
|
||||||
CustomerUser,
|
|
||||||
CustomerStats,
|
// Helper functions
|
||||||
Customer,
|
combineToUser, // Domain helper: UserAuth + WhmcsClient → User
|
||||||
AddressFormData,
|
addressFormToRequest,
|
||||||
} from './schema';
|
} 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";
|
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 {
|
export type {
|
||||||
|
// Request params
|
||||||
|
WhmcsAddClientParams,
|
||||||
|
WhmcsValidateLoginParams,
|
||||||
|
WhmcsCreateSsoTokenParams,
|
||||||
|
// Response types
|
||||||
|
WhmcsClientResponse,
|
||||||
WhmcsAddClientResponse,
|
WhmcsAddClientResponse,
|
||||||
WhmcsValidateLoginResponse,
|
WhmcsValidateLoginResponse,
|
||||||
WhmcsSsoResponse,
|
WhmcsSsoResponse,
|
||||||
} from "./providers/whmcs/raw.types";
|
} 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";
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Customer Domain - Providers
|
* 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 * as Portal from "./portal";
|
||||||
|
export * as Whmcs from "./whmcs";
|
||||||
export const Whmcs = WhmcsModule;
|
|
||||||
|
|
||||||
export { WhmcsModule };
|
|
||||||
export * from "./whmcs";
|
|
||||||
|
|||||||
9
packages/domain/customer/providers/portal/index.ts
Normal file
9
packages/domain/customer/providers/portal/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Portal Provider
|
||||||
|
*
|
||||||
|
* Handles mapping from Prisma (portal database) to UserAuth domain type
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./mapper";
|
||||||
|
export * from "./types";
|
||||||
|
|
||||||
31
packages/domain/customer/providers/portal/mapper.ts
Normal file
31
packages/domain/customer/providers/portal/mapper.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
27
packages/domain/customer/providers/portal/types.ts
Normal file
27
packages/domain/customer/providers/portal/types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -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 "./mapper";
|
||||||
export * from "./raw.types";
|
export * from "./raw.types";
|
||||||
|
|
||||||
|
// Re-export domain types for provider namespace convenience
|
||||||
|
export type { WhmcsClient, EmailPreferences, SubUser, Stats } from "../../schema";
|
||||||
|
|||||||
@ -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 { z } from "zod";
|
||||||
|
|
||||||
import type { Customer, CustomerAddress, CustomerStats } from "../../contract";
|
import type { WhmcsClient, Address } from "../../schema";
|
||||||
|
import { whmcsClientSchema, addressSchema } from "../../schema";
|
||||||
import {
|
import {
|
||||||
customerAddressSchema,
|
whmcsClientSchema as whmcsRawClientSchema,
|
||||||
customerEmailPreferencesSchema,
|
|
||||||
customerSchema,
|
|
||||||
customerStatsSchema,
|
|
||||||
customerUserSchema,
|
|
||||||
} from "../../schema";
|
|
||||||
import {
|
|
||||||
whmcsClientSchema,
|
|
||||||
whmcsClientResponseSchema,
|
whmcsClientResponseSchema,
|
||||||
whmcsClientStatsSchema,
|
type WhmcsClient as WhmcsRawClient,
|
||||||
type WhmcsClient,
|
|
||||||
type WhmcsClientResponse,
|
type WhmcsClientResponse,
|
||||||
type WhmcsClientStats,
|
|
||||||
whmcsCustomFieldSchema,
|
|
||||||
whmcsUserSchema,
|
|
||||||
} from "./raw.types";
|
} from "./raw.types";
|
||||||
|
|
||||||
const toBoolean = (value: unknown): boolean | undefined => {
|
/**
|
||||||
if (value === undefined || value === null) {
|
* Parse and validate WHMCS client response
|
||||||
return undefined;
|
*/
|
||||||
}
|
export const parseWhmcsClientResponse = (raw: unknown): WhmcsClientResponse =>
|
||||||
if (typeof value === "boolean") {
|
whmcsClientResponseSchema.parse(raw);
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toNumber = (value: unknown): number | undefined => {
|
/**
|
||||||
if (value === undefined || value === null) {
|
* Transform WHMCS client response to domain WhmcsClient
|
||||||
return undefined;
|
*/
|
||||||
}
|
export function transformWhmcsClientResponse(response: unknown): WhmcsClient {
|
||||||
if (typeof value === "number") {
|
const parsed = parseWhmcsClientResponse(response);
|
||||||
return value;
|
return transformWhmcsClient(parsed.client);
|
||||||
}
|
}
|
||||||
const parsed = Number.parseInt(String(value).replace(/[^0-9-]/g, ""), 10);
|
|
||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toString = (value: unknown): string | undefined => {
|
/**
|
||||||
if (value === undefined || value === null) {
|
* Transform raw WHMCS client to domain WhmcsClient
|
||||||
return undefined;
|
*
|
||||||
}
|
* Keeps raw WHMCS field names, only normalizes:
|
||||||
return String(value);
|
* - 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) {
|
* Normalize WHMCS address fields to domain Address structure
|
||||||
return undefined;
|
*/
|
||||||
}
|
function normalizeAddress(client: WhmcsRawClient): Address | undefined {
|
||||||
|
const address = addressSchema.parse({
|
||||||
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({
|
|
||||||
address1: client.address1 ?? null,
|
address1: client.address1 ?? null,
|
||||||
address2: client.address2 ?? null,
|
address2: client.address2 ?? null,
|
||||||
city: client.city ?? null,
|
city: client.city ?? null,
|
||||||
@ -114,90 +57,28 @@ const normalizeAddress = (client: WhmcsClient): CustomerAddress | undefined => {
|
|||||||
postcode: client.postcode ?? null,
|
postcode: client.postcode ?? null,
|
||||||
country: client.country ?? null,
|
country: client.country ?? null,
|
||||||
countryCode: client.countrycode ?? null,
|
countryCode: client.countrycode ?? null,
|
||||||
phoneNumber: client.phonenumberformatted ?? client.telephoneNumber ?? client.phonenumber ?? null,
|
phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? null,
|
||||||
phoneCountryCode: client.phonecc ?? 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;
|
return hasValues ? address : undefined;
|
||||||
};
|
}
|
||||||
|
|
||||||
const normalizeEmailPreferences = (input: unknown) => customerEmailPreferencesSchema.parse(input ?? {});
|
/**
|
||||||
|
* Transform WHMCS client address to domain Address
|
||||||
const normalizeStats = (input: unknown): CustomerStats | undefined => {
|
*/
|
||||||
const parsed = whmcsClientStatsSchema.safeParse(input);
|
export const transformWhmcsClientAddress = (raw: unknown): Address | undefined => {
|
||||||
if (!parsed.success || !parsed.data) {
|
const client = whmcsRawClientSchema.parse(raw);
|
||||||
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);
|
|
||||||
return normalizeAddress(client);
|
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 = (
|
export const prepareWhmcsClientAddressUpdate = (
|
||||||
address: Partial<CustomerAddress>
|
address: Partial<Address>
|
||||||
): Record<string, unknown> => {
|
): Record<string, unknown> => {
|
||||||
const update: Record<string, unknown> = {};
|
const update: Record<string, unknown> = {};
|
||||||
if (address.address1 !== undefined) update.address1 = address.address1 ?? "";
|
if (address.address1 !== undefined) update.address1 = address.address1 ?? "";
|
||||||
|
|||||||
@ -1,5 +1,58 @@
|
|||||||
import { z } from "zod";
|
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 booleanLike = z.union([z.boolean(), z.number(), z.string()]);
|
||||||
const numberLike = z.union([z.number(), z.string()]);
|
const numberLike = z.union([z.number(), z.string()]);
|
||||||
|
|
||||||
|
|||||||
@ -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 { z } from "zod";
|
||||||
|
|
||||||
import { countryCodeSchema } from "../common/schema";
|
import { countryCodeSchema } from "../common/schema";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
const stringOrNull = z.union([z.string(), z.null()]);
|
const stringOrNull = z.union([z.string(), z.null()]);
|
||||||
const booleanLike = z.union([z.boolean(), z.number(), z.string()]);
|
const booleanLike = z.union([z.boolean(), z.number(), z.string()]);
|
||||||
const numberLike = z.union([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(),
|
address1: stringOrNull.optional(),
|
||||||
address2: stringOrNull.optional(),
|
address2: stringOrNull.optional(),
|
||||||
city: stringOrNull.optional(),
|
city: stringOrNull.optional(),
|
||||||
@ -18,147 +56,6 @@ export const customerAddressSchema = z.object({
|
|||||||
phoneCountryCode: stringOrNull.optional(),
|
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({
|
export const addressFormSchema = z.object({
|
||||||
address1: z.string().min(1, "Address line 1 is required").max(200, "Address line 1 is too long").trim(),
|
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(),
|
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(),
|
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;
|
* UserAuth - Authentication state from portal database
|
||||||
const trimmed = value?.trim();
|
*
|
||||||
return trimmed ? trimmed : null;
|
* 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),
|
address1: emptyToNull(form.address1),
|
||||||
address2: emptyToNull(form.address2 ?? null),
|
address2: emptyToNull(form.address2 ?? null),
|
||||||
city: emptyToNull(form.city),
|
city: emptyToNull(form.city),
|
||||||
@ -192,24 +255,60 @@ export const addressFormToRequest = (form: AddressFormData): CustomerAddress =>
|
|||||||
phoneNumber: emptyToNull(form.phoneNumber ?? null),
|
phoneNumber: emptyToNull(form.phoneNumber ?? null),
|
||||||
phoneCountryCode: emptyToNull(form.phoneCountryCode ?? null),
|
phoneCountryCode: emptyToNull(form.phoneCountryCode ?? null),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type CustomerAddressSchema = typeof customerAddressSchema;
|
/**
|
||||||
export type CustomerSchema = typeof customerSchema;
|
* Combine UserAuth and WhmcsClient into User
|
||||||
export type CustomerStatsSchema = typeof customerStatsSchema;
|
*
|
||||||
|
* This is the single source of truth for constructing User from its sources.
|
||||||
export const addressSchema = customerAddressSchema;
|
* Maps raw WHMCS field names to normalized User field names.
|
||||||
export type AddressSchema = typeof addressSchema;
|
*
|
||||||
|
* @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 User = z.infer<typeof userSchema>;
|
||||||
export type CustomerEmailPreferences = z.infer<typeof customerEmailPreferencesSchema>;
|
export type UserAuth = z.infer<typeof userAuthSchema>;
|
||||||
export type CustomerUser = z.infer<typeof customerUserSchema>;
|
export type UserRole = "USER" | "ADMIN";
|
||||||
export type CustomerStats = z.infer<typeof customerStatsSchema>;
|
export type Address = z.infer<typeof addressSchema>;
|
||||||
export type Customer = z.infer<typeof customerSchema>;
|
|
||||||
export type AddressFormData = z.infer<typeof addressFormSchema>;
|
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 };
|
||||||
|
|||||||
@ -25,3 +25,12 @@ export interface UpdateMappingRequest {
|
|||||||
whmcsClientId?: number;
|
whmcsClientId?: number;
|
||||||
sfAccountId?: string;
|
sfAccountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result for mapping operations
|
||||||
|
*/
|
||||||
|
export interface MappingValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|||||||
@ -4,3 +4,13 @@
|
|||||||
|
|
||||||
export * from "./contract";
|
export * from "./contract";
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
|
export * from "./validation";
|
||||||
|
|
||||||
|
// Re-export types for convenience
|
||||||
|
export type {
|
||||||
|
MappingSearchFilters,
|
||||||
|
MappingStats,
|
||||||
|
BulkMappingOperation,
|
||||||
|
BulkMappingResult,
|
||||||
|
} from "./schema";
|
||||||
|
export type { MappingValidationResult } from "./contract";
|
||||||
|
|||||||
@ -34,3 +34,61 @@ export const userIdMappingSchema: z.ZodType<UserIdMapping> = z.object({
|
|||||||
createdAt: z.union([z.string(), z.date()]),
|
createdAt: z.union([z.string(), z.date()]),
|
||||||
updatedAt: 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>;
|
||||||
|
|||||||
203
packages/domain/mappings/validation.ts
Normal file
203
packages/domain/mappings/validation.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SalesforceProductFieldMap } from "../catalog/contract";
|
import type { SalesforceProductFieldMap } from "../catalog/contract";
|
||||||
import type { SalesforceAccountFieldMap } from "../customer/contract";
|
import type { SalesforceAccountFieldMap } from "../customer";
|
||||||
import type { UserIdMapping } from "../mappings/contract";
|
import type { UserIdMapping } from "../mappings/contract";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -120,10 +120,6 @@ export type UserMapping = Pick<UserIdMapping, "userId" | "whmcsClientId" | "sfAc
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
// Fulfillment order types
|
|
||||||
FulfillmentOrderProduct,
|
|
||||||
FulfillmentOrderItem,
|
|
||||||
FulfillmentOrderDetails,
|
|
||||||
// Order item types
|
// Order item types
|
||||||
OrderItemSummary,
|
OrderItemSummary,
|
||||||
OrderItemDetails,
|
OrderItemDetails,
|
||||||
|
|||||||
@ -29,10 +29,6 @@ export * from "./validation";
|
|||||||
|
|
||||||
// Re-export types for convenience
|
// Re-export types for convenience
|
||||||
export type {
|
export type {
|
||||||
// Fulfillment order types
|
|
||||||
FulfillmentOrderProduct,
|
|
||||||
FulfillmentOrderItem,
|
|
||||||
FulfillmentOrderDetails,
|
|
||||||
// Order item types
|
// Order item types
|
||||||
OrderItemSummary,
|
OrderItemSummary,
|
||||||
OrderItemDetails,
|
OrderItemDetails,
|
||||||
|
|||||||
@ -4,29 +4,12 @@
|
|||||||
* Transforms normalized order data to WHMCS API format.
|
* Transforms normalized order data to WHMCS API format.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FulfillmentOrderItem } from "../../contract";
|
import type { OrderDetails, OrderItemDetails } from "../../contract";
|
||||||
import {
|
import {
|
||||||
type WhmcsOrderItem,
|
type WhmcsOrderItem,
|
||||||
type WhmcsAddOrderParams,
|
type WhmcsAddOrderParams,
|
||||||
type WhmcsAddOrderPayload,
|
type WhmcsAddOrderPayload,
|
||||||
whmcsOrderItemSchema,
|
|
||||||
} from "./raw.types";
|
} 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 {
|
export interface OrderItemMappingResult {
|
||||||
whmcsItems: WhmcsOrderItem[];
|
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();
|
const normalized = cycle.trim().toLowerCase();
|
||||||
if (normalized.includes("monthly")) return "monthly";
|
if (normalized.includes("monthly")) return "monthly";
|
||||||
if (normalized.includes("one")) return "onetime";
|
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(
|
export function mapOrderItemToWhmcs(
|
||||||
item: FulfillmentOrderItem,
|
item: OrderItemDetails,
|
||||||
index = 0
|
index = 0
|
||||||
): WhmcsOrderItem {
|
): WhmcsOrderItem {
|
||||||
const parsed = fulfillmentOrderItemSchema.parse(item);
|
if (!item.product?.whmcsProductId) {
|
||||||
|
throw new Error(`Order item ${index} missing WHMCS product ID`);
|
||||||
if (!parsed.product) {
|
|
||||||
throw new Error(`Order item ${index} missing product information`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const whmcsItem: WhmcsOrderItem = {
|
const whmcsItem: WhmcsOrderItem = {
|
||||||
productId: parsed.product.whmcsProductId,
|
productId: item.product.whmcsProductId,
|
||||||
billingCycle: normalizeBillingCycle(parsed.product.billingCycle),
|
billingCycle: normalizeBillingCycle(item.billingCycle),
|
||||||
quantity: parsed.quantity,
|
quantity: item.quantity,
|
||||||
};
|
};
|
||||||
|
|
||||||
return whmcsItem;
|
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(
|
export function mapOrderToWhmcsItems(
|
||||||
items: FulfillmentOrderItem[]
|
orderDetails: OrderDetails
|
||||||
): OrderItemMappingResult {
|
): 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");
|
throw new Error("No order items provided for WHMCS mapping");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,9 +67,10 @@ export function mapFulfillmentOrderItems(
|
|||||||
let serviceItems = 0;
|
let serviceItems = 0;
|
||||||
let activationItems = 0;
|
let activationItems = 0;
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
orderDetails.items.forEach((item, index) => {
|
||||||
const mapped = mapFulfillmentOrderItem(item, index);
|
const mapped = mapOrderItemToWhmcs(item, index);
|
||||||
whmcsItems.push(mapped);
|
whmcsItems.push(mapped);
|
||||||
|
|
||||||
if (mapped.billingCycle === "monthly") {
|
if (mapped.billingCycle === "monthly") {
|
||||||
serviceItems++;
|
serviceItems++;
|
||||||
} else if (mapped.billingCycle === "onetime") {
|
} else if (mapped.billingCycle === "onetime") {
|
||||||
@ -195,4 +180,3 @@ export function createOrderNotes(sfOrderId: string, additionalNotes?: string): s
|
|||||||
|
|
||||||
return notes.join("; ");
|
return notes.join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,33 +6,6 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
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
|
// Order Item Summary Schema
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -247,11 +220,6 @@ export type SfOrderIdParam = z.infer<typeof sfOrderIdParamSchema>;
|
|||||||
// Inferred Types from Schemas (Schema-First Approach)
|
// 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
|
// Order item types
|
||||||
export type OrderItemSummary = z.infer<typeof orderItemSummarySchema>;
|
export type OrderItemSummary = z.infer<typeof orderItemSummarySchema>;
|
||||||
export type OrderItemDetails = z.infer<typeof orderItemDetailsSchema>;
|
export type OrderItemDetails = z.infer<typeof orderItemDetailsSchema>;
|
||||||
|
|||||||
@ -83,6 +83,9 @@ export function getMainServiceSkus(skus: string[]): string[] {
|
|||||||
/**
|
/**
|
||||||
* Complete order validation including all SKU business rules
|
* 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:
|
* Validates:
|
||||||
* - Basic order structure (from orderBusinessValidationSchema)
|
* - Basic order structure (from orderBusinessValidationSchema)
|
||||||
* - SIM orders have service plan + activation fee
|
* - SIM orders have service plan + activation fee
|
||||||
@ -91,48 +94,28 @@ export function getMainServiceSkus(skus: string[]): string[] {
|
|||||||
*/
|
*/
|
||||||
export const orderWithSkuValidationSchema = orderBusinessValidationSchema
|
export const orderWithSkuValidationSchema = orderBusinessValidationSchema
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => data.orderType !== "SIM" || hasSimServicePlan(data.skus),
|
||||||
if (data.orderType === "SIM") {
|
|
||||||
return hasSimServicePlan(data.skus);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
message: "SIM orders must include a SIM service plan",
|
message: "SIM orders must include a SIM service plan",
|
||||||
path: ["skus"],
|
path: ["skus"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => data.orderType !== "SIM" || hasSimActivationFee(data.skus),
|
||||||
if (data.orderType === "SIM") {
|
|
||||||
return hasSimActivationFee(data.skus);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
message: "SIM orders require an activation fee",
|
message: "SIM orders require an activation fee",
|
||||||
path: ["skus"],
|
path: ["skus"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => data.orderType !== "VPN" || hasVpnActivationFee(data.skus),
|
||||||
if (data.orderType === "VPN") {
|
|
||||||
return hasVpnActivationFee(data.skus);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
message: "VPN orders require an activation fee",
|
message: "VPN orders require an activation fee",
|
||||||
path: ["skus"],
|
path: ["skus"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => data.orderType !== "Internet" || hasInternetServicePlan(data.skus),
|
||||||
if (data.orderType === "Internet") {
|
|
||||||
return hasInternetServicePlan(data.skus);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
message: "Internet orders require a service plan",
|
message: "Internet orders require a service plan",
|
||||||
path: ["skus"],
|
path: ["skus"],
|
||||||
|
|||||||
@ -22,6 +22,8 @@
|
|||||||
"./common/*": "./dist/common/*",
|
"./common/*": "./dist/common/*",
|
||||||
"./auth": "./dist/auth/index.js",
|
"./auth": "./dist/auth/index.js",
|
||||||
"./auth/*": "./dist/auth/*",
|
"./auth/*": "./dist/auth/*",
|
||||||
|
"./customer": "./dist/customer/index.js",
|
||||||
|
"./customer/*": "./dist/customer/*",
|
||||||
"./dashboard": "./dist/dashboard/index.js",
|
"./dashboard": "./dist/dashboard/index.js",
|
||||||
"./dashboard/*": "./dist/dashboard/*",
|
"./dashboard/*": "./dist/dashboard/*",
|
||||||
"./mappings": "./dist/mappings/index.js",
|
"./mappings": "./dist/mappings/index.js",
|
||||||
|
|||||||
@ -25,8 +25,11 @@ export type {
|
|||||||
// Provider adapters
|
// Provider adapters
|
||||||
export * as Providers from "./providers";
|
export * as Providers from "./providers";
|
||||||
|
|
||||||
// Re-export provider response types
|
// Re-export provider raw types (request and response)
|
||||||
export type {
|
export type {
|
||||||
|
// Request params
|
||||||
|
WhmcsGetPayMethodsParams,
|
||||||
|
// Response types
|
||||||
WhmcsPaymentMethod,
|
WhmcsPaymentMethod,
|
||||||
WhmcsPaymentMethodListResponse,
|
WhmcsPaymentMethodListResponse,
|
||||||
WhmcsPaymentGateway,
|
WhmcsPaymentGateway,
|
||||||
|
|||||||
@ -1,9 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* WHMCS Payments Provider - Raw Types
|
* 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";
|
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({
|
export const whmcsPaymentMethodRawSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
payment_type: z.string().optional(),
|
payment_type: z.string().optional(),
|
||||||
|
|||||||
@ -20,12 +20,18 @@ export type {
|
|||||||
SubscriptionList,
|
SubscriptionList,
|
||||||
SubscriptionQueryParams,
|
SubscriptionQueryParams,
|
||||||
SubscriptionQuery,
|
SubscriptionQuery,
|
||||||
|
SubscriptionStats,
|
||||||
|
SimActionResponse,
|
||||||
|
SimPlanChangeResult,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
// Provider adapters
|
// Provider adapters
|
||||||
export * as Providers from "./providers";
|
export * as Providers from "./providers";
|
||||||
|
|
||||||
// Re-export provider response types
|
// Re-export provider raw types (request and response)
|
||||||
export type {
|
export type {
|
||||||
|
// Request params
|
||||||
|
WhmcsGetClientsProductsParams,
|
||||||
|
// Response types
|
||||||
WhmcsProductListResponse,
|
WhmcsProductListResponse,
|
||||||
} from "./providers/whmcs/raw.types";
|
} from "./providers/whmcs/raw.types";
|
||||||
|
|||||||
@ -1,11 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* WHMCS Subscriptions Provider - Raw Types
|
* 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";
|
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
|
// Custom field structure
|
||||||
export const whmcsCustomFieldSchema = z.object({
|
export const whmcsCustomFieldSchema = z.object({
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
|
|||||||
@ -75,6 +75,39 @@ export type SubscriptionQueryParams = z.infer<typeof subscriptionQueryParamsSche
|
|||||||
export const subscriptionQuerySchema = subscriptionQueryParamsSchema;
|
export const subscriptionQuerySchema = subscriptionQueryParamsSchema;
|
||||||
export type SubscriptionQuery = SubscriptionQueryParams;
|
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)
|
// 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 SubscriptionCycle = z.infer<typeof subscriptionCycleSchema>;
|
||||||
export type Subscription = z.infer<typeof subscriptionSchema>;
|
export type Subscription = z.infer<typeof subscriptionSchema>;
|
||||||
export type SubscriptionList = z.infer<typeof subscriptionListSchema>;
|
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>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user