- Change default login URL from production to test Salesforce environment for safer development and testing.
1106 lines
33 KiB
Markdown
1106 lines
33 KiB
Markdown
# 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<TEntity, TCreateInput, TUpdateInput, TWhereUnique, TWhere> {
|
|
constructor(protected readonly prisma: PrismaService) {}
|
|
|
|
protected abstract get delegate(): {
|
|
findUnique(args: { where: TWhereUnique }): Promise<TEntity | null>;
|
|
findFirst(args: { where: TWhere }): Promise<TEntity | null>;
|
|
findMany(args: {
|
|
where?: TWhere;
|
|
skip?: number;
|
|
take?: number;
|
|
orderBy?: unknown;
|
|
}): Promise<TEntity[]>;
|
|
create(args: { data: TCreateInput }): Promise<TEntity>;
|
|
update(args: { where: TWhereUnique; data: TUpdateInput }): Promise<TEntity>;
|
|
delete(args: { where: TWhereUnique }): Promise<TEntity>;
|
|
count(args?: { where?: TWhere }): Promise<number>;
|
|
};
|
|
|
|
async findById(where: TWhereUnique): Promise<TEntity | null> {
|
|
return this.delegate.findUnique({ where });
|
|
}
|
|
|
|
async findOne(where: TWhere): Promise<TEntity | null> {
|
|
return this.delegate.findFirst({ where });
|
|
}
|
|
|
|
async findMany(
|
|
where?: TWhere,
|
|
options?: { skip?: number; take?: number; orderBy?: unknown }
|
|
): Promise<TEntity[]> {
|
|
return this.delegate.findMany({ where, ...options });
|
|
}
|
|
|
|
async create(data: TCreateInput): Promise<TEntity> {
|
|
return this.delegate.create({ data });
|
|
}
|
|
|
|
async update(where: TWhereUnique, data: TUpdateInput): Promise<TEntity> {
|
|
return this.delegate.update({ where, data });
|
|
}
|
|
|
|
async delete(where: TWhereUnique): Promise<TEntity> {
|
|
return this.delegate.delete({ where });
|
|
}
|
|
|
|
async count(where?: TWhere): Promise<number> {
|
|
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<T>(
|
|
fn: (tx: Prisma.TransactionClient) => Promise<T>,
|
|
options?: TransactionOptions
|
|
): Promise<TransactionResult<T>> {
|
|
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
|