diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts index e727c008..52f8914c 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts @@ -99,7 +99,7 @@ export class SalesforceConnection { @Inject(Logger) private readonly logger: Logger ) { this.connection = new jsforce.Connection({ - loginUrl: this.configService.get("SF_LOGIN_URL") || "https://login.salesforce.com", + loginUrl: this.configService.get("SF_LOGIN_URL") || "https://test.salesforce.com", }); } @@ -175,7 +175,7 @@ export class SalesforceConnection { const privateKeyPath = this.configService.get("SF_PRIVATE_KEY_PATH"); const privateKeyEnv = this.configService.get("SF_PRIVATE_KEY_BASE64"); const audience = - this.configService.get("SF_LOGIN_URL") || "https://login.salesforce.com"; + this.configService.get("SF_LOGIN_URL") || "https://test.salesforce.com"; if (!username || !clientId || (!privateKeyEnv && !privateKeyPath)) { const devMessage = diff --git a/docs/plans/2026-02-25-bff-domain-structure-cleanup-design.md b/docs/plans/2026-02-25-bff-domain-structure-cleanup-design.md new file mode 100644 index 00000000..069641ec --- /dev/null +++ b/docs/plans/2026-02-25-bff-domain-structure-cleanup-design.md @@ -0,0 +1,320 @@ +# BFF & Domain Structure Cleanup Design + +**Date:** 2026-02-25 +**Status:** Approved +**Approach:** Incremental — 5 independent PRs, each shippable alone + +## Context + +A thorough codebase audit of the BFF and domain packages identified structural improvements to make the codebase cleaner, more predictable, and more maintainable — aligned with enterprise standards (Stripe, Netflix, Airbnb patterns). + +**What's already strong:** Controller patterns, logging (nestjs-pino), configuration (Zod-validated env), security (Helmet, CORS, rate limiting), error registry (82 domain error codes), domain schemas (schema-first Zod approach), queue patterns (BullMQ with idempotency). + +**What needs improvement:** TypeScript strictness, database access abstraction, provider error handling, auth module organization, and domain schema validation gaps. + +## PR 1: Enable TypeScript Strict Mode + +### Problem + +`apps/bff/tsconfig.json` has no `strict`, no `noImplicitAny`, no `strictNullChecks`. Type safety bugs pass silently through compilation. + +### Design + +1. Enable `strict: true` in the base `tsconfig.node.json` (affects both BFF and domain) +2. Keep `strictPropertyInitialization: false` in `apps/bff/tsconfig.json` only (NestJS DI injects via decorators — properties are initialized but not in constructors) +3. Fix all compilation errors surfaced — each one is a real bug the compiler was hiding + +### What strict enables + +- `noImplicitAny` — no silent `any` inference +- `strictNullChecks` — `null`/`undefined` must be handled explicitly +- `strictBindCallApply` — correct types for `.bind()`, `.call()`, `.apply()` +- `strictFunctionTypes` — contravariant parameter checking +- `noImplicitThis` — explicit `this` typing +- `alwaysStrict` — emit `"use strict"` in all files + +### Risk + +Medium — will surface potentially many errors. Each fix prevents a real bug. + +--- + +## PR 2: Repository + Unit of Work Layer + +### Problem + +Raw `prisma.entity.findUnique()` calls scattered throughout services. Tightly coupled to Prisma API, hard to test and mock, no transactional boundaries. + +### Design + +``` +apps/bff/src/infra/database/ +├── prisma.service.ts # Existing — unchanged +├── unit-of-work.service.ts # NEW — transaction coordinator +├── base.repository.ts # NEW — generic typed CRUD base +└── repositories/ + ├── user.repository.ts # Per-entity repositories + ├── sim.repository.ts + ├── subscription.repository.ts + └── index.ts # Barrel exports +``` + +#### Base Repository + +Generic class typed to the Prisma model delegate. Provides: + +- `findById(id)` — single record by ID +- `findOne(where)` — single record by arbitrary criteria +- `findMany(where, options?)` — filtered list with pagination +- `create(data)` — insert new record +- `update(id, data)` — update by ID +- `delete(id)` — soft or hard delete by ID +- `count(where?)` — count matching records + +Each concrete repository (e.g., `UserRepository`) extends `BaseRepository` and can add domain-specific queries. + +#### Unit of Work + +Wraps `prisma.$transaction()` with interactive transactions. Provides a transactional Prisma client to repositories, ensuring atomicity across multiple entity operations. + +```typescript +class UnitOfWork { + async execute(fn: (tx: TransactionalClient) => Promise): Promise { + return this.prisma.$transaction(fn); + } +} +``` + +Repositories accept an optional `tx` parameter to participate in a transaction. + +#### Migration Strategy + +Services switch from: + +```typescript +this.prisma.user.findUnique({ where: { id } }); +``` + +to: + +```typescript +this.userRepository.findById(id); +``` + +Change is mechanical and can be done file-by-file. + +### Risk + +Low — additive change, then mechanical migration. + +--- + +## PR 3: Provider Error Codes + Typed Error Classes + +### Problem + +Provider error detection uses brittle string matching. Example from `order-fulfillment-error.service.ts`: checks if error message includes `"Payment method missing"`. Breaks when provider messages change. + +### Design — Two Layers + +#### Layer 1: Provider Error Classes (Infrastructure) + +``` +apps/bff/src/integrations/common/errors/ +├── base-provider.error.ts # BaseProviderError (extends Error) +├── whmcs.errors.ts # WhmcsApiError, WhmcsAuthError, WhmcsNotFoundError +├── salesforce.errors.ts # SalesforceApiError, SalesforceQueryError +├── freebit.errors.ts # FreebitApiError, FreebitTimeoutError +└── index.ts +``` + +`BaseProviderError` structure: + +```typescript +abstract class BaseProviderError extends Error { + abstract readonly provider: "whmcs" | "salesforce" | "freebit"; + abstract readonly providerCode: string; + abstract readonly domainErrorCode: ErrorCode; + readonly providerMessage: string; + readonly httpStatus: number; +} +``` + +Each concrete error class (e.g., `WhmcsPaymentMethodMissingError`) carries structured metadata and maps to a domain `ErrorCode`. + +#### Layer 2: Provider Error Code Enums (Domain) + +``` +packages/domain/common/ +├── errors.ts # Existing — add provider error mapping +├── provider-errors.ts # NEW — provider-specific error codes +``` + +Provider error code enums define the known error conditions per provider: + +```typescript +export const WHMCS_ERROR = { + PAYMENT_METHOD_MISSING: "WHMCS_PAYMENT_METHOD_MISSING", + CLIENT_NOT_FOUND: "WHMCS_CLIENT_NOT_FOUND", + INVOICE_NOT_FOUND: "WHMCS_INVOICE_NOT_FOUND", + // ... +} as const; +``` + +#### Error Flow + +1. Integration service catches raw provider error +2. Translates to typed error class: `throw new WhmcsPaymentMethodMissingError(rawMessage)` +3. UnifiedExceptionFilter catches `BaseProviderError` subclass +4. Maps `domainErrorCode` → API error response using existing error registry + +#### Migration + +Replace all `instanceof Error` + string checks with `instanceof WhmcsNotFoundError` etc. + +### Risk + +Low — new code first, then migrate string checks one at a time. + +--- + +## PR 4: Auth Feature-Based Decomposition + +### Problem + +Auth module has 48 providers in one module. Handles login, registration, password reset, OTP, sessions, and get-started flows in a single flat structure. Hard to scan, reason about, and modify independently. + +### Design — 6 Feature Modules + +``` +apps/bff/src/modules/auth/ +├── auth.module.ts # Orchestrator — imports feature modules +├── auth.controller.ts # Thin top-level router (if needed) +├── auth-orchestrator.service.ts # Cross-feature coordination +│ +├── login/ +│ ├── login.module.ts +│ ├── login.controller.ts # /auth/login routes +│ └── services/ +│ ├── login.service.ts +│ └── login-otp-workflow.service.ts +│ +├── get-started/ +│ ├── get-started.module.ts +│ ├── get-started.controller.ts # /auth/get-started routes +│ └── services/ +│ ├── get-started-coordinator.service.ts +│ ├── get-started-session.service.ts +│ └── get-started-otp.service.ts +│ +├── tokens/ +│ ├── tokens.module.ts +│ └── services/ +│ ├── auth-token.service.ts +│ ├── token-blacklist.service.ts +│ └── token-refresh.service.ts +│ +├── otp/ +│ ├── otp.module.ts +│ └── services/ +│ ├── otp-generation.service.ts +│ └── otp-verification.service.ts +│ +├── sessions/ +│ ├── sessions.module.ts +│ └── services/ +│ ├── session-manager.service.ts +│ └── trusted-device.service.ts +│ +├── password-reset/ +│ ├── password-reset.module.ts +│ ├── password-reset.controller.ts # /auth/password-reset routes +│ └── services/ +│ +└── shared/ + ├── guards/ # GlobalAuthGuard, PermissionsGuard + ├── decorators/ # @Public, @OptionalAuth + └── services/ # Shared step services +``` + +### Key Principles + +- Each feature module owns its full vertical slice (controller, services) +- `tokens/` and `otp/` are shared infrastructure modules imported by login and get-started +- `sessions/` manages session state and trusted devices +- Top-level `auth.module.ts` becomes a thin orchestrator that imports feature modules and re-exports shared guards +- NestJS `RouterModule` mounts feature controllers under `/auth/*` prefixes +- Shared guards and decorators remain in `shared/` and are exported globally + +### Migration Strategy + +1. Create the new module/directory structure +2. Move existing services into their feature modules +3. Update module imports/exports +4. Verify all routes still work via integration tests +5. Remove the old flat structure + +### Risk + +Medium — restructure touches many files and NestJS DI wiring. Must verify all auth flows work after migration. + +--- + +## PR 5: Tighten Domain Schema Validation + +### Problem + +`support/schema.ts` uses `z.string()` for status, priority, and category fields instead of the defined enum schemas. The mapper already transforms Japanese Salesforce values to English display labels before parsing, so the strict enums will work correctly. + +### Design + +Tighten the schema: + +```typescript +// Before (loose): +status: z.string(), +priority: z.string(), +category: z.string().nullable(), + +// After (strict): +status: supportCaseStatusSchema, +priority: supportCasePrioritySchema, +category: supportCaseCategorySchema.nullable(), +``` + +Also add JSDoc comments to intentional escape hatches: + +- `customer/contract.ts` interfaces with `[key: string]: unknown` +- `common/errors.ts` with `z.record(z.string(), z.unknown())` + +### Behavior Change + +If an unmapped Salesforce status arrives, `supportCaseSchema.parse()` will throw a Zod validation error instead of silently passing through a raw Japanese string. This is the correct behavior — unknown statuses should fail loudly so the mapping can be updated. + +### Risk + +Low — small targeted change. The `getStatusDisplayLabel()` fallback (`?? status`) currently passes through unknown values silently, which masks configuration issues. + +--- + +## Execution Order + +PRs are independent but this order minimizes friction: + +1. **PR 1 (TypeScript strict)** — surfaces hidden issues that may affect other PRs +2. **PR 5 (Schema tightening)** — smallest, quick win +3. **PR 3 (Provider error codes)** — new infrastructure, no migration yet +4. **PR 2 (Repository + UoW)** — new infrastructure, then mechanical migration +5. **PR 4 (Auth decomposition)** — largest change, do last when other patterns are stable + +## Out of Scope (Future Work) + +These items were identified in the audit but deferred: + +- **Testing infrastructure** — zero test files exist; separate initiative needed +- **Circuit breaker pattern** — for external service resilience +- **Request correlation across async operations** — AsyncLocalStorage propagation +- **Database health checks** — add to /health endpoint +- **DLQ visibility** — expose failed queue jobs +- **API versioning strategy** — document approach for breaking changes +- **Prometheus metrics export** — replace in-memory queue metrics diff --git a/docs/plans/2026-02-25-bff-domain-structure-cleanup-plan.md b/docs/plans/2026-02-25-bff-domain-structure-cleanup-plan.md new file mode 100644 index 00000000..cd126f11 --- /dev/null +++ b/docs/plans/2026-02-25-bff-domain-structure-cleanup-plan.md @@ -0,0 +1,1105 @@ +# BFF & Domain Structure Cleanup — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Improve BFF and domain package structure for cleanliness, predictability, and maintainability through 4 incremental PRs. + +**Architecture:** Incremental approach — each PR is independently shippable. Order: (1) Schema tightening, (2) Provider error codes + classes, (3) Repository + UoW layer, (4) Auth feature decomposition. + +**Tech Stack:** NestJS 11, Prisma 7, Zod, TypeScript (strict mode already enabled) + +**Key insight from audit:** `tsconfig.base.json` already has `strict: true` + `exactOptionalPropertyTypes` + `noUncheckedIndexedAccess`. No TS strictness changes needed. + +--- + +## PR 1: Tighten Domain Schema Validation + +**Branch:** `refactor/tighten-support-schema` + +**Context:** `packages/domain/support/schema.ts` defines strict enum schemas (lines 36-38) but doesn't use them in the main `supportCaseSchema` (lines 48-50). Instead it uses `z.string()`. The Salesforce mapper at `packages/domain/support/providers/salesforce/mapper.ts:200-205` already transforms Japanese values to English display labels BEFORE calling `supportCaseSchema.parse()`, so strict enums will work. + +### Task 1: Tighten supportCaseSchema + +**Files:** + +- Modify: `packages/domain/support/schema.ts:44-55` + +**Step 1: Update the schema to use defined enum schemas** + +Replace lines 48-50: + +```typescript +// Before: +status: z.string(), // Allow any status from Salesforce +priority: z.string(), // Allow any priority from Salesforce +category: z.string().nullable(), // Maps to Salesforce Type field + +// After: +status: supportCaseStatusSchema, +priority: supportCasePrioritySchema, +category: supportCaseCategorySchema.nullable(), +``` + +**Step 2: Update the filter schema to use enums too** + +Replace lines 69-76: + +```typescript +// Before: +export const supportCaseFilterSchema = z + .object({ + status: z.string().optional(), + priority: z.string().optional(), + category: z.string().optional(), + search: z.string().trim().min(1).optional(), + }) + .default({}); + +// After: +export const supportCaseFilterSchema = z + .object({ + status: supportCaseStatusSchema.optional(), + priority: supportCasePrioritySchema.optional(), + category: supportCaseCategorySchema.optional(), + search: z.string().trim().min(1).optional(), + }) + .default({}); +``` + +**Step 3: Build domain package and type-check** + +Run: `pnpm domain:build && pnpm type-check` +Expected: PASS — the mapper already outputs values matching these enums. + +**Step 4: Commit** + +```bash +git add packages/domain/support/schema.ts +git commit -m "refactor: tighten support schema to use defined enum validators" +``` + +### Task 2: Add JSDoc to intentional escape hatches + +**Files:** + +- Modify: `packages/domain/customer/contract.ts` — find interfaces with `[key: string]: unknown` + +**Step 1: Read the customer contract file to find exact locations** + +Read: `packages/domain/customer/contract.ts` + +**Step 2: Add JSDoc comments explaining the escape hatches** + +For each interface with `[key: string]: unknown`, add: + +```typescript +/** + * Raw Salesforce record — intentionally permissive. + * The Salesforce API returns org-specific fields that vary by configuration. + * Domain mappers validate specific fields; unknown fields are ignored. + */ +``` + +**Step 3: Build and type-check** + +Run: `pnpm domain:build && pnpm type-check` +Expected: PASS + +**Step 4: Commit** + +```bash +git add packages/domain/customer/contract.ts +git commit -m "docs: add JSDoc to intentional escape hatches in raw type interfaces" +``` + +### Task 3: Create PR + +Run: `gh pr create --title "refactor: tighten support schema enum validation" --body "..."` + +--- + +## PR 2: Provider Error Codes + Typed Error Classes + +**Branch:** `refactor/provider-error-classes` + +**Context:** Error detection in `apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts` and `apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts` uses brittle `message.includes()` string matching. The existing `DomainHttpException` at `apps/bff/src/core/http/domain-http.exception.ts` already carries structured error codes. We need provider-specific error classes that extend this pattern. + +### Task 1: Create provider error code enums in domain + +**Files:** + +- Create: `packages/domain/common/provider-errors.ts` +- Modify: `packages/domain/common/index.ts` — add export + +**Step 1: Create the provider error code enums** + +Create `packages/domain/common/provider-errors.ts`: + +```typescript +/** + * Provider-specific error codes for structured error detection. + * + * These codes identify known error conditions from external providers. + * Used by provider error classes in the BFF to replace brittle string matching. + */ + +export const WhmcsProviderError = { + // Authentication + AUTH_FAILED: "WHMCS_AUTH_FAILED", + INVALID_CREDENTIALS: "WHMCS_INVALID_CREDENTIALS", + + // Not found + CLIENT_NOT_FOUND: "WHMCS_CLIENT_NOT_FOUND", + INVOICE_NOT_FOUND: "WHMCS_INVOICE_NOT_FOUND", + PRODUCT_NOT_FOUND: "WHMCS_PRODUCT_NOT_FOUND", + + // Validation + VALIDATION_ERROR: "WHMCS_VALIDATION_ERROR", + + // Network/Infrastructure + TIMEOUT: "WHMCS_TIMEOUT", + NETWORK_ERROR: "WHMCS_NETWORK_ERROR", + RATE_LIMITED: "WHMCS_RATE_LIMITED", + HTTP_ERROR: "WHMCS_HTTP_ERROR", + + // Generic + API_ERROR: "WHMCS_API_ERROR", +} as const; + +export type WhmcsProviderErrorCode = (typeof WhmcsProviderError)[keyof typeof WhmcsProviderError]; + +export const SalesforceProviderError = { + // Authentication + SESSION_EXPIRED: "SF_SESSION_EXPIRED", + AUTH_FAILED: "SF_AUTH_FAILED", + + // Query + QUERY_ERROR: "SF_QUERY_ERROR", + RECORD_NOT_FOUND: "SF_RECORD_NOT_FOUND", + + // Network/Infrastructure + TIMEOUT: "SF_TIMEOUT", + NETWORK_ERROR: "SF_NETWORK_ERROR", + RATE_LIMITED: "SF_RATE_LIMITED", + + // Generic + API_ERROR: "SF_API_ERROR", +} as const; + +export type SalesforceProviderErrorCode = + (typeof SalesforceProviderError)[keyof typeof SalesforceProviderError]; + +export const FreebitProviderError = { + ACCOUNT_NOT_FOUND: "FREEBIT_ACCOUNT_NOT_FOUND", + TIMEOUT: "FREEBIT_TIMEOUT", + API_ERROR: "FREEBIT_API_ERROR", +} as const; + +export type FreebitProviderErrorCode = + (typeof FreebitProviderError)[keyof typeof FreebitProviderError]; +``` + +**Step 2: Export from domain/common/index.ts** + +Add to barrel file: + +```typescript +export { + WhmcsProviderError, + SalesforceProviderError, + FreebitProviderError, + type WhmcsProviderErrorCode, + type SalesforceProviderErrorCode, + type FreebitProviderErrorCode, +} from "./provider-errors.js"; +``` + +**Step 3: Build domain** + +Run: `pnpm domain:build` +Expected: PASS + +**Step 4: Commit** + +```bash +git add packages/domain/common/provider-errors.ts packages/domain/common/index.ts +git commit -m "feat: add provider error code enums to domain package" +``` + +### Task 2: Create BaseProviderError and provider error classes + +**Files:** + +- Create: `apps/bff/src/integrations/common/errors/base-provider.error.ts` +- Create: `apps/bff/src/integrations/common/errors/whmcs.errors.ts` +- Create: `apps/bff/src/integrations/common/errors/salesforce.errors.ts` +- Create: `apps/bff/src/integrations/common/errors/freebit.errors.ts` +- Create: `apps/bff/src/integrations/common/errors/index.ts` + +**Step 1: Create base provider error** + +Create `apps/bff/src/integrations/common/errors/base-provider.error.ts`: + +```typescript +import type { HttpStatus } from "@nestjs/common"; +import type { ErrorCodeType } from "@customer-portal/domain/common"; + +/** + * Base class for all provider-specific errors. + * + * Carries structured metadata for error classification without string matching. + * The UnifiedExceptionFilter detects subclasses and maps them to API responses. + */ +export abstract class BaseProviderError extends Error { + abstract readonly provider: "whmcs" | "salesforce" | "freebit"; + abstract readonly providerCode: string; + abstract readonly domainErrorCode: ErrorCodeType; + abstract readonly httpStatus: HttpStatus; + + constructor( + message: string, + public readonly providerMessage: string, + public readonly cause?: unknown + ) { + super(message); + this.name = this.constructor.name; + } +} +``` + +**Step 2: Create WHMCS error classes** + +Create `apps/bff/src/integrations/common/errors/whmcs.errors.ts`: + +```typescript +import { HttpStatus } from "@nestjs/common"; +import { ErrorCode } from "@customer-portal/domain/common"; +import { WhmcsProviderError } from "@customer-portal/domain/common"; +import { BaseProviderError } from "./base-provider.error.js"; + +export class WhmcsApiError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.API_ERROR; + readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; + readonly httpStatus = HttpStatus.BAD_GATEWAY; + + constructor(message: string, providerMessage: string, cause?: unknown) { + super(message, providerMessage, cause); + } +} + +export class WhmcsNotFoundError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode: string; + readonly domainErrorCode = ErrorCode.NOT_FOUND; + readonly httpStatus = HttpStatus.NOT_FOUND; + + constructor( + resource: "client" | "invoice" | "product", + providerMessage: string, + cause?: unknown + ) { + super(`WHMCS ${resource} not found`, providerMessage, cause); + const codeMap = { + client: WhmcsProviderError.CLIENT_NOT_FOUND, + invoice: WhmcsProviderError.INVOICE_NOT_FOUND, + product: WhmcsProviderError.PRODUCT_NOT_FOUND, + } as const; + this.providerCode = codeMap[resource]; + } +} + +export class WhmcsAuthError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.AUTH_FAILED; + readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; + readonly httpStatus = HttpStatus.SERVICE_UNAVAILABLE; + + constructor(providerMessage: string, cause?: unknown) { + super("WHMCS authentication failed", providerMessage, cause); + } +} + +export class WhmcsInvalidCredentialsError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.INVALID_CREDENTIALS; + readonly domainErrorCode = ErrorCode.INVALID_CREDENTIALS; + readonly httpStatus = HttpStatus.UNAUTHORIZED; + + constructor(providerMessage: string, cause?: unknown) { + super("Invalid login credentials", providerMessage, cause); + } +} + +export class WhmcsValidationError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.VALIDATION_ERROR; + readonly domainErrorCode = ErrorCode.VALIDATION_FAILED; + readonly httpStatus = HttpStatus.BAD_REQUEST; + + constructor(providerMessage: string, cause?: unknown) { + super("WHMCS validation failed", providerMessage, cause); + } +} + +export class WhmcsTimeoutError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.TIMEOUT; + readonly domainErrorCode = ErrorCode.TIMEOUT; + readonly httpStatus = HttpStatus.GATEWAY_TIMEOUT; + + constructor(providerMessage: string, cause?: unknown) { + super("WHMCS request timed out", providerMessage, cause); + } +} + +export class WhmcsNetworkError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.NETWORK_ERROR; + readonly domainErrorCode = ErrorCode.NETWORK_ERROR; + readonly httpStatus = HttpStatus.BAD_GATEWAY; + + constructor(providerMessage: string, cause?: unknown) { + super("WHMCS network error", providerMessage, cause); + } +} + +export class WhmcsRateLimitError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.RATE_LIMITED; + readonly domainErrorCode = ErrorCode.RATE_LIMITED; + readonly httpStatus = HttpStatus.TOO_MANY_REQUESTS; + + constructor(providerMessage: string, cause?: unknown) { + super("WHMCS rate limit exceeded", providerMessage, cause); + } +} + +export class WhmcsHttpError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.HTTP_ERROR; + readonly domainErrorCode: string; + readonly httpStatus: HttpStatus; + + constructor( + upstreamStatus: number, + providerMessage: string, + domainErrorCode: string, + httpStatus: HttpStatus, + cause?: unknown + ) { + super(`WHMCS HTTP ${upstreamStatus} error`, providerMessage, cause); + this.domainErrorCode = domainErrorCode; + this.httpStatus = httpStatus; + } +} +``` + +**Step 3: Create Salesforce and Freebit error classes** (follow same pattern as WHMCS) + +Create `apps/bff/src/integrations/common/errors/salesforce.errors.ts` — classes: `SalesforceApiError`, `SalesforceSessionExpiredError`, `SalesforceQueryError`, `SalesforceTimeoutError`, `SalesforceNetworkError`, `SalesforceRateLimitError`. + +Create `apps/bff/src/integrations/common/errors/freebit.errors.ts` — classes: `FreebitApiError`, `FreebitAccountNotFoundError`, `FreebitTimeoutError`. + +**Step 4: Create barrel file** + +Create `apps/bff/src/integrations/common/errors/index.ts`: + +```typescript +export { BaseProviderError } from "./base-provider.error.js"; +export * from "./whmcs.errors.js"; +export * from "./salesforce.errors.js"; +export * from "./freebit.errors.js"; +``` + +**Step 5: Type-check** + +Run: `pnpm type-check` +Expected: PASS + +**Step 6: Commit** + +```bash +git add apps/bff/src/integrations/common/ +git commit -m "feat: add typed provider error classes for WHMCS, Salesforce, Freebit" +``` + +### Task 3: Update UnifiedExceptionFilter to handle BaseProviderError + +**Files:** + +- Modify: `apps/bff/src/core/http/exception.filter.ts` + +**Step 1: Read the current exception filter** + +Read: `apps/bff/src/core/http/exception.filter.ts` + +**Step 2: Add BaseProviderError detection in extractErrorDetails()** + +In the error extraction logic, add a check before the generic Error handling: + +```typescript +import { BaseProviderError } from "@bff/integrations/common/errors/index.js"; + +// In extractErrorDetails(): +if (exception instanceof BaseProviderError) { + return { + status: exception.httpStatus, + code: exception.domainErrorCode, + message: exception.message, + }; +} +``` + +**Step 3: Type-check** + +Run: `pnpm type-check` +Expected: PASS + +**Step 4: Commit** + +```bash +git add apps/bff/src/core/http/exception.filter.ts +git commit -m "feat: add BaseProviderError support to unified exception filter" +``` + +### Task 4: Migrate WhmcsErrorHandlerService to use error classes + +**Files:** + +- Modify: `apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts` + +**Step 1: Read the current handler (already read above, 314 lines)** + +**Step 2: Refactor to throw typed error classes instead of DomainHttpException with string matching** + +Replace `handleApiError()`: + +```typescript +handleApiError(errorResponse: WhmcsErrorResponse, action: string): never { + const message = errorResponse.message; + const errorCode = errorResponse.errorcode; + + // ValidateLogin: user credentials are wrong + if (action === "ValidateLogin" && this.isValidateLoginInvalidCredentials(message, errorCode)) { + throw new WhmcsInvalidCredentialsError(message); + } + + // Not-found outcomes + if (this.isNotFoundError(action, message)) { + const resource = this.getNotFoundResource(action); + throw new WhmcsNotFoundError(resource, message); + } + + // Auth failures (API key issues) + if (this.isAuthenticationError(message, errorCode)) { + throw new WhmcsAuthError(message); + } + + // Validation failures + if (this.isValidationError(message, errorCode)) { + throw new WhmcsValidationError(message); + } + + // Default + throw new WhmcsApiError(`WHMCS ${action} failed`, message); +} +``` + +Replace `handleRequestError()`: + +```typescript +handleRequestError(error: unknown, context?: string): never { + const message = extractErrorMessage(error); + + if (this.isTimeoutError(error)) { + this.logger.warn("WHMCS request timeout", { error: message }); + throw new WhmcsTimeoutError(message, error); + } + + if (this.isNetworkError(error)) { + this.logger.warn("WHMCS network error", { error: message }); + throw new WhmcsNetworkError(message, error); + } + + if (this.isRateLimitError(error)) { + throw new WhmcsRateLimitError(message, error); + } + + // HTTP status errors + const httpStatusError = this.parseHttpStatusError(message); + if (httpStatusError) { + const mapped = this.mapHttpStatusToDomainError(httpStatusError.status); + throw new WhmcsHttpError( + httpStatusError.status, + message, + mapped.code, + mapped.domainStatus, + error + ); + } + + // Re-throw provider errors as-is + if (error instanceof BaseProviderError) throw error; + if (error instanceof DomainHttpException) throw error; + + throw new WhmcsApiError( + context ? `WHMCS ${context} failed` : "WHMCS operation failed", + message, + error + ); +} +``` + +Note: The private `isTimeoutError`, `isNetworkError`, `isRateLimitError` methods still use string matching on Node.js error messages — this is acceptable because Node.js network errors ARE identified by their message strings. The key improvement is that business-level errors (not found, auth, validation) use structured codes, and all callers get typed error classes they can `instanceof` check. + +**Step 3: Type-check** + +Run: `pnpm type-check` +Expected: PASS + +**Step 4: Commit** + +```bash +git add apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts +git commit -m "refactor: migrate WHMCS error handler to typed provider error classes" +``` + +### Task 5: Migrate SalesforceErrorHandlerService to use error classes + +**Files:** + +- Modify: `apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts` + +Follow same pattern as Task 4. Read the file first, then replace `DomainHttpException` throws with typed `SalesforceSessionExpiredError`, `SalesforceTimeoutError`, etc. + +**Step 1: Read the current Salesforce error handler** +**Step 2: Refactor to throw typed error classes** +**Step 3: Type-check** + +Run: `pnpm type-check` +Expected: PASS + +**Step 4: Commit** + +```bash +git add apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts +git commit -m "refactor: migrate Salesforce error handler to typed provider error classes" +``` + +### Task 6: Migrate FreebitErrorHandlerService + +**Files:** + +- Modify: `apps/bff/src/integrations/freebit/services/freebit-error-handler.service.ts` + +Follow same pattern. Read file first, then migrate. + +**Step 1-4:** Same as Tasks 4 and 5. + +```bash +git commit -m "refactor: migrate Freebit error handler to typed provider error classes" +``` + +### Task 7: Create PR + +Run: `gh pr create --title "refactor: add typed provider error classes replacing string matching" --body "..."` + +--- + +## PR 3: Repository + Unit of Work Layer + +**Branch:** `refactor/repository-pattern` + +**Context:** 14 files import PrismaService directly. An existing `UserAuthRepository` at `apps/bff/src/modules/users/infra/user-auth.repository.ts` already demonstrates the repository pattern. An existing `TransactionService` at `apps/bff/src/infra/database/services/transaction.service.ts` provides transaction management with retries and rollback coordination. We need to formalize the repository pattern with a base class and add a Unit of Work that leverages the existing TransactionService. + +**Existing patterns to build on:** + +- `UserAuthRepository` — already wraps PrismaService for user operations +- `TransactionService` — already provides `$transaction()` with retries, rollback, isolation levels +- `PrismaModule` (global) — provides PrismaService, TransactionService, DistributedTransactionService + +### Task 1: Create BaseRepository + +**Files:** + +- Create: `apps/bff/src/infra/database/base.repository.ts` + +**Step 1: Create the generic base repository** + +```typescript +import type { PrismaService } from "./prisma.service.js"; + +/** + * Generic base repository for Prisma entities. + * + * Provides type-safe CRUD operations that can be overridden by concrete repositories. + * Accepts an optional transactional client for UoW participation. + * + * @typeParam TDelegate - The Prisma model delegate (e.g., PrismaService['user']) + * @typeParam TEntity - The entity type returned by queries + * @typeParam TCreateInput - The input type for create operations + * @typeParam TUpdateInput - The input type for update operations + * @typeParam TWhereUnique - The unique identifier filter type + * @typeParam TWhere - The general where filter type + */ +export abstract class BaseRepository { + constructor(protected readonly prisma: PrismaService) {} + + protected abstract get delegate(): { + findUnique(args: { where: TWhereUnique }): Promise; + findFirst(args: { where: TWhere }): Promise; + findMany(args: { + where?: TWhere; + skip?: number; + take?: number; + orderBy?: unknown; + }): Promise; + create(args: { data: TCreateInput }): Promise; + update(args: { where: TWhereUnique; data: TUpdateInput }): Promise; + delete(args: { where: TWhereUnique }): Promise; + count(args?: { where?: TWhere }): Promise; + }; + + async findById(where: TWhereUnique): Promise { + return this.delegate.findUnique({ where }); + } + + async findOne(where: TWhere): Promise { + return this.delegate.findFirst({ where }); + } + + async findMany( + where?: TWhere, + options?: { skip?: number; take?: number; orderBy?: unknown } + ): Promise { + return this.delegate.findMany({ where, ...options }); + } + + async create(data: TCreateInput): Promise { + return this.delegate.create({ data }); + } + + async update(where: TWhereUnique, data: TUpdateInput): Promise { + return this.delegate.update({ where, data }); + } + + async delete(where: TWhereUnique): Promise { + return this.delegate.delete({ where }); + } + + async count(where?: TWhere): Promise { + return this.delegate.count({ where }); + } +} +``` + +**Step 2: Type-check** + +Run: `pnpm type-check` +Expected: PASS + +**Step 3: Commit** + +```bash +git add apps/bff/src/infra/database/base.repository.ts +git commit -m "feat: add BaseRepository generic class for typed CRUD operations" +``` + +### Task 2: Create UnitOfWork service + +**Files:** + +- Create: `apps/bff/src/infra/database/unit-of-work.service.ts` + +**Step 1: Create UoW that wraps the existing TransactionService** + +````typescript +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import type { Prisma } from "@prisma/client"; +import { + TransactionService, + type TransactionOptions, + type TransactionResult, +} from "./services/transaction.service.js"; + +/** + * Unit of Work — coordinates multi-entity operations within a single transaction. + * + * Wraps the existing TransactionService to provide a cleaner API for orchestrating + * multiple repository operations atomically. + * + * @example + * ```typescript + * const result = await this.unitOfWork.execute(async (tx) => { + * const user = await tx.user.create({ data: userData }); + * await tx.idMapping.create({ data: { userId: user.id, ... } }); + * return user; + * }); + * ``` + */ +@Injectable() +export class UnitOfWork { + constructor( + private readonly transactionService: TransactionService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Execute a function within a database transaction. + * All Prisma operations on the `tx` client are atomic. + */ + async execute( + fn: (tx: Prisma.TransactionClient) => Promise, + options?: TransactionOptions + ): Promise> { + return this.transactionService.executeTransaction(async (tx, context) => { + context.addOperation("UnitOfWork.execute"); + return fn(tx); + }, options); + } +} +```` + +**Step 2: Register in PrismaModule** + +Modify `apps/bff/src/infra/database/prisma.module.ts`: + +- Add `UnitOfWork` to providers and exports + +**Step 3: Type-check** + +Run: `pnpm type-check` +Expected: PASS + +**Step 4: Commit** + +```bash +git add apps/bff/src/infra/database/unit-of-work.service.ts apps/bff/src/infra/database/prisma.module.ts +git commit -m "feat: add UnitOfWork service wrapping TransactionService" +``` + +### Task 3: Create concrete repositories + +**Files:** + +- Create: `apps/bff/src/infra/database/repositories/sim.repository.ts` +- Create: `apps/bff/src/infra/database/repositories/id-mapping.repository.ts` +- Create: `apps/bff/src/infra/database/repositories/audit-log.repository.ts` +- Create: `apps/bff/src/infra/database/repositories/index.ts` + +**Step 1: Identify which Prisma models need repositories** + +Check which models are accessed directly via PrismaService across the 14 files. The existing `UserAuthRepository` handles `user`. Create repositories for other models: `sim`, `idMapping`, `auditLog`, etc. + +**Step 2: Create each repository extending BaseRepository** + +Example `sim.repository.ts`: + +```typescript +import { Injectable } from "@nestjs/common"; +import type { Sim, Prisma } from "@prisma/client"; +import { BaseRepository } from "../base.repository.js"; +import { PrismaService } from "../prisma.service.js"; + +@Injectable() +export class SimRepository extends BaseRepository< + Sim, + Prisma.SimCreateInput, + Prisma.SimUpdateInput, + Prisma.SimWhereUniqueInput, + Prisma.SimWhereInput +> { + constructor(prisma: PrismaService) { + super(prisma); + } + + protected get delegate() { + return this.prisma.sim; + } +} +``` + +**Step 3: Create barrel file and register in PrismaModule** + +**Step 4: Type-check** + +Run: `pnpm type-check` +Expected: PASS + +**Step 5: Commit** + +```bash +git add apps/bff/src/infra/database/repositories/ apps/bff/src/infra/database/prisma.module.ts +git commit -m "feat: add concrete repository classes for Sim, IdMapping, AuditLog" +``` + +### Task 4: Migrate services to use repositories + +**Files:** + +- Modify: Each of the 14 files that import PrismaService (except `prisma.service.ts` and `prisma.module.ts` themselves, `transaction.service.ts`, and `health.controller.ts`) + +**Step 1: Migrate one service at a time** + +For each service, replace: + +```typescript +// Before: +constructor(private readonly prisma: PrismaService) {} +// ... +await this.prisma.sim.update({ where: { id }, data }); + +// After: +constructor(private readonly simRepository: SimRepository) {} +// ... +await this.simRepository.update({ id }, data); +``` + +**Step 2: Update the module that provides each service to import the repository** + +**Step 3: Type-check after each migration** + +Run: `pnpm type-check` +Expected: PASS + +**Step 4: Commit per service group** + +```bash +git commit -m "refactor: migrate SIM services to use SimRepository" +git commit -m "refactor: migrate audit services to use AuditLogRepository" +# etc. +``` + +### Task 5: Create PR + +Run: `gh pr create --title "refactor: add repository + unit of work layer for database access" --body "..."` + +--- + +## PR 4: Auth Feature-Based Decomposition + +**Branch:** `refactor/auth-feature-modules` + +**Context:** The auth module has 59 TypeScript files and 48 NestJS providers in a single `auth.module.ts`. It already has good directory layering (`presentation/`, `infra/`, `application/`), but all providers are registered in one module, making the DI graph hard to reason about. + +**Current auth directory structure:** + +``` +modules/auth/ +├── auth.module.ts # 48 providers +├── auth.types.ts +├── application/ +│ ├── auth-orchestrator.service.ts +│ ├── auth-health.service.ts +│ └── auth-login.service.ts +├── presentation/http/ +│ ├── auth.controller.ts # Main auth routes +│ ├── get-started.controller.ts # Get-started routes +│ ├── guards/ +│ ├── interceptors/ +│ └── utils/ +├── infra/ +│ ├── token/ # 8 token services +│ ├── otp/ # 2 OTP services +│ ├── login/ # 1 login session service +│ ├── trusted-device/ # 1 trusted device service +│ ├── rate-limiting/ # 1 rate limiting service +│ └── workflows/ # 10 workflow services + 6 step services +├── constants/ +├── decorators/ +└── utils/ +``` + +**Target: 6 feature modules** — tokens, otp, sessions, login, get-started, password-reset — plus a shared module for guards/decorators and a thin orchestrator module. + +### Task 1: Create TokensModule + +**Files:** + +- Create: `apps/bff/src/modules/auth/tokens/tokens.module.ts` + +**Step 1: Read all token service files** + +Read: All files in `apps/bff/src/modules/auth/infra/token/` + +**Step 2: Create the TokensModule** + +Move the 8 token services into a self-contained module. The files stay in their current location — we just create a new NestJS module that owns them: + +```typescript +import { Module } from "@nestjs/common"; +import { JoseJwtService } from "../infra/token/jose-jwt.service.js"; +import { TokenGeneratorService } from "../infra/token/token-generator.service.js"; +import { TokenRefreshService } from "../infra/token/token-refresh.service.js"; +import { TokenStorageService } from "../infra/token/token-storage.service.js"; +import { TokenRevocationService } from "../infra/token/token-revocation.service.js"; +import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js"; +import { AuthTokenService } from "../infra/token/token.service.js"; +import { PasswordResetTokenService } from "../infra/token/password-reset-token.service.js"; +import { TokenMigrationService } from "../infra/token/token-migration.service.js"; + +@Module({ + providers: [ + JoseJwtService, + TokenGeneratorService, + TokenRefreshService, + TokenStorageService, + TokenRevocationService, + TokenBlacklistService, + AuthTokenService, + PasswordResetTokenService, + TokenMigrationService, + ], + exports: [ + JoseJwtService, + AuthTokenService, + TokenBlacklistService, + TokenRefreshService, + PasswordResetTokenService, + TokenMigrationService, + ], +}) +export class TokensModule {} +``` + +**Step 3: Type-check** + +Run: `pnpm type-check` +Expected: PASS + +**Step 4: Commit** + +```bash +git add apps/bff/src/modules/auth/tokens/ +git commit -m "feat: create TokensModule for auth token services" +``` + +### Task 2: Create OtpModule + +**Files:** + +- Create: `apps/bff/src/modules/auth/otp/otp.module.ts` + +Follow same pattern — owns `OtpService` and `GetStartedSessionService` from `infra/otp/`. + +### Task 3: Create SessionsModule + +**Files:** + +- Create: `apps/bff/src/modules/auth/sessions/sessions.module.ts` + +Owns `LoginSessionService` (from `infra/login/`), `TrustedDeviceService` (from `infra/trusted-device/`). + +### Task 4: Create LoginModule + +**Files:** + +- Create: `apps/bff/src/modules/auth/login/login.module.ts` + +Owns `AuthLoginService` (from `application/`), `LoginOtpWorkflowService` (from `infra/workflows/`), `FailedLoginThrottleGuard`, `LoginResultInterceptor`. +Imports: TokensModule, SessionsModule, OtpModule. + +### Task 5: Create GetStartedModule + +**Files:** + +- Create: `apps/bff/src/modules/auth/get-started/get-started.module.ts` + +Owns `GetStartedCoordinator`, all signup workflow services, step services. +Imports: TokensModule, OtpModule. + +### Task 6: Create PasswordResetModule + +**Files:** + +- Create: `apps/bff/src/modules/auth/password-reset/password-reset.module.ts` + +Owns `PasswordWorkflowService`. +Imports: TokensModule. + +### Task 7: Create SharedAuthModule + +**Files:** + +- Create: `apps/bff/src/modules/auth/shared/shared-auth.module.ts` + +Owns guards (`GlobalAuthGuard`, `PermissionsGuard`), decorators (`@Public`, `@OptionalAuth`), `AuthRateLimitService`. + +### Task 8: Refactor AuthModule to orchestrator + +**Files:** + +- Modify: `apps/bff/src/modules/auth/auth.module.ts` + +**Step 1: Replace 48 inline providers with module imports** + +```typescript +@Module({ + imports: [ + TokensModule, + OtpModule, + SessionsModule, + LoginModule, + GetStartedModule, + PasswordResetModule, + SharedAuthModule, + // External modules + UsersModule, + MappingsModule, + IntegrationsModule, + CacheModule, + WorkflowModule, + ], + providers: [AuthOrchestrator, AuthHealthService], + exports: [AuthOrchestrator, TokensModule, SharedAuthModule], +}) +export class AuthModule {} +``` + +**Step 2: Verify all routes work** + +Run: `pnpm type-check` +Expected: PASS + +**Step 3: Commit** + +```bash +git add apps/bff/src/modules/auth/ +git commit -m "refactor: decompose AuthModule into 6 feature modules" +``` + +### Task 9: Verify and create PR + +**Step 1: Full type-check and lint** + +Run: `pnpm type-check && pnpm lint` +Expected: PASS + +**Step 2: Create PR** + +Run: `gh pr create --title "refactor: decompose auth module into feature-based sub-modules" --body "..."` + +--- + +## Execution Checklist + +| PR | Branch | Estimated Tasks | Dependencies | +| --- | --------------------------------- | --------------- | ------------ | +| 1 | `refactor/tighten-support-schema` | 3 tasks | None | +| 2 | `refactor/provider-error-classes` | 7 tasks | None | +| 3 | `refactor/repository-pattern` | 5 tasks | None | +| 4 | `refactor/auth-feature-modules` | 9 tasks | None | + +All PRs are independent and can be worked on in parallel or any order. + +## Review Checkpoints + +After each PR: + +1. Run `pnpm type-check` — must pass +2. Run `pnpm lint` — must pass +3. Run `pnpm domain:build` — must pass (for domain changes) +4. Manually verify no circular imports: check that feature modules only import what they declare +5. Verify no behavioral changes: all existing API routes return identical responses