Refactor Domain Mappings and Update Import Paths

- Removed the domain mappings module, consolidating related types and schemas into the id-mappings feature.
- Updated import paths across the BFF to reflect the new structure, ensuring compliance with import hygiene rules.
- Cleaned up unused files and optimized the codebase for better maintainability and clarity.
This commit is contained in:
barsa 2025-12-26 17:27:22 +09:00
parent 3030d12138
commit 465a62a3e8
133 changed files with 415 additions and 1183 deletions

View File

@ -1,530 +1,92 @@
# Customer Portal - AI Agent Coding Rules
## Customer Portal - AI Coding Rules (Focused)
This document defines the coding standards, architecture patterns, and conventions for the Customer Portal project. AI agents (Cursor, Copilot, etc.) must follow these rules when generating or modifying code.
These rules are the fast “guardrails” for AI agents in this repo. Detailed patterns live in docs; when unsure, read them first.
---
### Read before coding
## 📚 Documentation First
- `docs/README.md` (entrypoint)
- `docs/development/` (BFF/Portal/Domain patterns)
- `docs/architecture/` (boundaries)
- `docs/integrations/` (external API details)
**Before writing any code, read and understand the relevant documentation:**
Rule: **Never guess** endpoint behavior or payload shapes. Find docs or an existing implementation first.
- Start from `docs/README.md` for navigation
- Check `docs/development/` for implementation patterns
- Review `docs/architecture/` for system design
- Check `docs/integrations/` for external API details
### Repo boundaries (non-negotiable)
**Never guess API response structures or endpoint signatures.** Always read the documentation or existing implementations first.
- **Domain (`packages/domain/`)**: shared contracts + Zod validation + cross-app utilities. Framework-agnostic.
- **BFF (`apps/bff/`)**: NestJS HTTP boundary + orchestration + integrations (Salesforce/WHMCS/Freebit).
- **Portal (`apps/portal/`)**: Next.js UI; pages are thin wrappers over feature modules.
---
### Import rules (enforced by ESLint)
## 🏗️ Monorepo Architecture
- **Allowed (Portal + BFF)**:
- `@customer-portal/domain/<module>`
- `@customer-portal/domain/toolkit`
- **Allowed (BFF only)**:
- `@customer-portal/domain/<module>/providers`
- **Forbidden everywhere**:
- `@customer-portal/domain` (root import)
- any deep import like `@customer-portal/domain/<module>/**`
- any deep import like `@customer-portal/domain/<module>/providers/**`
- **Forbidden in Portal**:
- `@customer-portal/domain/*/providers` (Portal must never import provider adapters)
```
apps/
portal/ # Next.js 15 frontend (React 19)
bff/ # NestJS 11 Backend-for-Frontend
packages/
domain/ # Pure domain types/schemas/utils (isomorphic)
```
Examples:
### Technology Stack
- **Frontend**: Next.js 15, React 19, Tailwind CSS 4, shadcn/ui, TanStack Query, Zustand
- **Backend**: NestJS 11, Prisma 6, PostgreSQL 17, Redis 7, Pino
- **Integrations**: Salesforce (jsforce + Pub/Sub API), WHMCS, Freebit
- **Validation**: Zod (shared between frontend and backend)
---
## 📦 Domain Package (`@customer-portal/domain`)
The domain package is the **single source of truth** for all types and validation.
### Structure
```
packages/domain/
├── {domain}/
│ ├── contract.ts # Normalized types (provider-agnostic)
│ ├── schema.ts # Zod schemas + derived types
│ ├── constants.ts # Domain constants
│ ├── providers/ # Provider-specific adapters (BFF only!)
│ │ └── {provider}/
│ │ ├── raw.types.ts # Raw API response types
│ │ └── mapper.ts # Transform raw → domain
│ └── index.ts # Public exports
├── common/ # Shared types (ApiResponse, pagination)
└── toolkit/ # Utilities (formatting, validation helpers)
```
### Import Rules
```typescript
// ✅ CORRECT: Import from domain module
import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
import { invoiceSchema, invoiceListSchema } from "@customer-portal/domain/billing";
// ✅ CORRECT (BFF only): Import provider mappers
import { Providers } from "@customer-portal/domain/billing/providers";
// ❌ WRONG: Never import from domain root
import { Invoice } from "@customer-portal/domain";
// ❌ WRONG: Never deep-import internals
import { Invoice } from "@customer-portal/domain/billing/contract";
import { mapper } from "@customer-portal/domain/billing/providers/whmcs/mapper";
// ❌ WRONG: Portal must NEVER import providers
// (in apps/portal/**)
import { Providers } from "@customer-portal/domain/billing/providers"; // FORBIDDEN
```
### Schema-First Approach
Always define Zod schemas first, then derive TypeScript types:
```typescript
// ✅ CORRECT: Schema-first
export const invoiceSchema = z.object({
id: z.number(),
status: z.enum(["Paid", "Unpaid", "Cancelled"]),
total: z.number(),
});
export type Invoice = z.infer<typeof invoiceSchema>;
// ❌ WRONG: Type-only (no runtime validation)
export interface Invoice {
id: number;
status: string;
total: number;
}
```
---
## 🖥️ BFF (NestJS Backend)
### Directory Structure
```
apps/bff/src/
├── modules/ # Feature-aligned modules
│ └── {feature}/
│ ├── {feature}.controller.ts
│ ├── {feature}.module.ts
│ └── services/
│ ├── {feature}-orchestrator.service.ts
│ └── {feature}.service.ts
├── integrations/ # External system clients
│ └── {provider}/
│ ├── services/
│ │ ├── {provider}-connection.service.ts
│ │ └── {provider}-{entity}.service.ts
│ ├── utils/
│ │ └── {entity}-query-builder.ts
│ └── {provider}.module.ts
├── core/ # Framework setup (guards, filters, config)
├── infra/ # Infrastructure (Redis, queues, logging)
└── main.ts
```
### Controller Pattern
Controllers use Zod DTOs via `createZodDto()` and the global `ZodValidationPipe`:
```typescript
import { Controller, Get, Query, Request } from "@nestjs/common";
import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import type { InvoiceList } from "@customer-portal/domain/billing";
import {
invoiceListQuerySchema,
invoiceListSchema,
} from "@customer-portal/domain/billing";
class InvoiceListQueryDto extends createZodDto(invoiceListQuerySchema) {}
class InvoiceListDto extends createZodDto(invoiceListSchema) {}
@Controller("invoices")
export class InvoicesController {
constructor(private readonly orchestrator: InvoicesOrchestratorService) {}
@Get()
@ZodResponse({ description: "List invoices", type: InvoiceListDto })
async getInvoices(
@Request() req: RequestWithUser,
@Query() query: InvoiceListQueryDto
): Promise<InvoiceList> {
return this.orchestrator.getInvoices(req.user.id, query);
}
}
```
**Controller Rules:**
- ❌ Never import `zod` directly in controllers
- ✅ Use schemas from `@customer-portal/domain/{module}`
- ✅ Use `createZodDto(schema)` for DTO classes
- ✅ Delegate all business logic to orchestrator services
### Integration Service Pattern
Integration services handle external API communication:
```typescript
import { Injectable } from "@nestjs/common";
import { SalesforceConnection } from "./salesforce-connection.service";
import { buildOrderSelectFields } from "../utils/order-query-builder";
import {
Providers,
type OrderDetails,
} from "@customer-portal/domain/orders";
@Injectable()
export class SalesforceOrderService {
constructor(private readonly sf: SalesforceConnection) {}
async getOrderById(orderId: string): Promise<OrderDetails | null> {
// 1. Build query (infrastructure concern)
const fields = buildOrderSelectFields().join(", ");
const soql = `SELECT ${fields} FROM Order WHERE Id = '${orderId}'`;
// 2. Execute query
const result = await this.sf.query(soql);
if (!result.records?.[0]) return null;
// 3. Transform with domain mapper (SINGLE transformation!)
return Providers.Salesforce.transformOrderDetails(result.records[0], []);
}
}
```
**Integration Service Rules:**
- ✅ Build queries in utils (query builders)
- ✅ Execute API calls
- ✅ Use domain mappers for transformation
- ✅ Return domain types
- ❌ Never add business logic
- ❌ Never create wrapper/transformer services
- ❌ Never transform data twice
### Error Handling
**Never expose sensitive information to customers:**
```typescript
// ✅ CORRECT: Generic user-facing message
throw new HttpException(
"Unable to process your request. Please try again.",
HttpStatus.BAD_REQUEST
);
// ❌ WRONG: Exposes internal details
throw new HttpException(
`WHMCS API error: ${error.message}`,
HttpStatus.BAD_REQUEST
);
```
Log detailed errors server-side, return generic messages to clients.
---
## 🌐 Portal (Next.js Frontend)
### Directory Structure
```
apps/portal/src/
├── app/ # Next.js App Router (thin route wrappers)
│ ├── (public)/ # Marketing + auth routes
│ ├── (authenticated)/ # Signed-in portal routes
│ └── api/ # API routes
├── components/ # Shared UI (design system)
│ ├── ui/ # Atoms (Button, Input, Card)
│ ├── layout/ # Layout components
│ └── common/ # Molecules (DataTable, SearchBar)
├── features/ # Feature modules
│ └── {feature}/
│ ├── components/ # Feature-specific UI
│ ├── hooks/ # React Query hooks
│ ├── services/ # API service functions
│ ├── views/ # Page-level views
│ └── index.ts # Public exports
├── lib/ # Core utilities
│ ├── api/ # HTTP client
│ ├── hooks/ # Shared hooks
│ ├── services/ # Shared services
│ └── utils/ # Utility functions
└── styles/ # Global styles
```
### Feature Module Pattern
```typescript
// features/billing/hooks/use-invoices.ts
import { useQuery } from "@tanstack/react-query";
import type { InvoiceList } from "@customer-portal/domain/billing";
import { billingService } from "../services";
export function useInvoices(params?: { status?: string }) {
return useQuery<InvoiceList>({
queryKey: ["invoices", params],
queryFn: () => billingService.getInvoices(params),
});
}
// features/billing/services/billing.service.ts
import { apiClient } from "@/lib/api";
import type { InvoiceList } from "@customer-portal/domain/billing";
export const billingService = {
async getInvoices(params?: { status?: string }): Promise<InvoiceList> {
const response = await apiClient.get("/invoices", { params });
return response.data;
},
};
// features/billing/index.ts
export * from "./hooks";
export * from "./components";
```
### Page Component Rules
Pages are thin shells that compose features:
```typescript
// app/(authenticated)/billing/page.tsx
import { InvoicesView } from "@/features/billing";
export default function BillingPage() {
return <InvoicesView />;
}
```
**Frontend Rules:**
- ✅ Pages delegate to feature views
- ✅ Data fetching lives in feature hooks
- ✅ Business logic lives in feature services
- ✅ Use `@/` path aliases
- ❌ Never call APIs directly in page components
- ❌ Never import provider types (only domain contracts)
### Import Patterns
```typescript
// Feature imports
import { LoginForm, useAuth } from "@/features/auth";
// Component imports
import { Button, Input } from "@/components/ui";
import { DataTable } from "@/components/common";
// Type imports (domain types only!)
```ts
// ✅ correct (apps)
import type { Invoice } from "@customer-portal/domain/billing";
import { invoiceSchema } from "@customer-portal/domain/billing";
// Utility imports
import { apiClient } from "@/lib/api";
// ✅ correct (BFF integrations only)
import { Whmcs } from "@customer-portal/domain/billing/providers";
// ❌ forbidden
import { Billing } from "@customer-portal/domain";
import { Invoice } from "@customer-portal/domain/billing/contract";
import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers/whmcs/mapper";
```
---
### Validation rules (Zod-first)
## ✅ Validation Patterns
- **Schemas live in domain**: `packages/domain/<module>/schema.ts`
- **Derive types from schemas**: `export type X = z.infer<typeof xSchema>`
- **Query params**: use `z.coerce.*` for URL strings.
### Query Parameters
### BFF rules (NestJS)
Use `z.coerce` for URL query strings:
- **Controllers are thin**:
- no business logic
- no `zod` imports in controllers
- use `createZodDto(schema)` + global `ZodValidationPipe`
- **Integrations are thin**:
- build queries in `apps/bff/src/integrations/<provider>/utils`
- fetch data
- transform once via domain mappers
- return domain types
- **Errors**:
- never leak sensitive details to clients
- log details server-side, return generic user messages
```typescript
// ✅ CORRECT: Coerce string to number
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().positive().max(100).optional(),
});
### Portal rules (Next.js)
// ❌ WRONG: Will fail on URL strings
export const paginationSchema = z.object({
page: z.number().optional(), // "1" !== 1
});
```
- **Pages are wrappers** under `apps/portal/src/app/**` (no API calls in pages)
- **Feature modules own logic** under `apps/portal/src/features/<feature>/**`
- hooks (React Query)
- services (API client calls)
- components/views (UI composition)
- **No provider imports** from domain in Portal.
### Request Body Validation
### Naming & safety
```typescript
// In domain schema
export const createOrderRequestSchema = z.object({
items: z.array(orderItemSchema).min(1),
shippingAddressId: z.string().uuid(),
});
export type CreateOrderRequest = z.infer<typeof createOrderRequestSchema>;
```
- No `any` in public APIs
- Avoid unsafe assertions
- No `console.log` in production code (use logger)
- Avoid `V2` suffix in service names
### Form Validation (Frontend)
### References
```typescript
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginRequestSchema, type LoginRequest } from "@customer-portal/domain/auth";
function LoginForm() {
const form = useForm<LoginRequest>({
resolver: zodResolver(loginRequestSchema),
});
// ...
}
```
---
## 🎨 UI Design Guidelines
Follow minimal, clean UI design principles:
- **Avoid excessive animations** - Use subtle, purposeful transitions
- **Clean typography** - Use system fonts or carefully chosen web fonts
- **Consistent spacing** - Follow the design system tokens
- **Accessibility first** - Proper ARIA labels, keyboard navigation
- **Mobile responsive** - Test on all breakpoints
Use shadcn/ui components and Tailwind CSS utilities.
---
## 🔒 Security Rules
1. **Never expose sensitive data** in error messages or responses
2. **Validate all inputs** at API boundaries (Zod schemas)
3. **Use parameterized queries** (Prisma handles this)
4. **Sanitize user content** before display
5. **Check authorization** in every endpoint
---
## 📝 Naming Conventions
### Files
- **Components**: PascalCase (`InvoiceCard.tsx`)
- **Hooks**: camelCase with `use` prefix (`useInvoices.ts`)
- **Services**: kebab-case (`billing.service.ts`)
- **Types/Schemas**: kebab-case (`invoice.schema.ts`)
- **Utils**: kebab-case (`format-currency.ts`)
### Code
- **Types/Interfaces**: PascalCase (`Invoice`, `OrderDetails`)
- **Schemas**: camelCase with `Schema` suffix (`invoiceSchema`)
- **Constants**: SCREAMING_SNAKE_CASE (`INVOICE_STATUS`)
- **Functions**: camelCase (`getInvoices`, `transformOrder`)
- **React Components**: PascalCase (`InvoiceList`)
### Services
- ❌ Avoid `V2` suffix in service names
- ✅ Use clear, descriptive names (`InvoicesOrchestratorService`)
---
## 🚫 Anti-Patterns to Avoid
### Domain Layer
- ❌ Framework-specific imports (no React/NestJS in domain)
- ❌ Circular dependencies
- ❌ Exposing raw provider types to application code
### BFF
- ❌ Business logic in controllers
- ❌ Direct Zod imports in controllers (use schemas from domain)
- ❌ Creating transformer/wrapper services
- ❌ Multiple data transformations
- ❌ Exposing internal error details
### Portal
- ❌ API calls in page components
- ❌ Business logic in UI components
- ❌ Importing provider types/mappers
- ❌ Duplicate type definitions
- ❌ Using `window.location` for navigation (use `next/link` or `useRouter`)
### General
- ❌ Using `any` type (especially in public APIs)
- ❌ Unsafe type assertions
- ❌ Console.log in production code (use proper logger)
- ❌ Guessing API response structures
---
## 🔄 Data Flow Summary
### Inbound (External API → Application)
```
External API Response
Raw Provider Types (domain/*/providers/*/raw.types.ts)
Provider Mapper (domain/*/providers/*/mapper.ts) [BFF only]
Zod Schema Validation (domain/*/schema.ts)
Domain Contract (domain/*/contract.ts)
Application Code (BFF services, Portal hooks)
```
### Outbound (Application → External API)
```
Application Intent
Domain Contract
Provider Mapper (BFF only)
Raw Provider Payload
External API Request
```
---
## 📋 Checklist for New Features
1. [ ] Define types in `packages/domain/{feature}/contract.ts`
2. [ ] Add Zod schemas in `packages/domain/{feature}/schema.ts`
3. [ ] Export from `packages/domain/{feature}/index.ts`
4. [ ] (If provider needed) Add raw types and mapper in `providers/{provider}/`
5. [ ] Create BFF module in `apps/bff/src/modules/{feature}/`
6. [ ] Create controller with Zod DTOs
7. [ ] Create orchestrator service
8. [ ] Create portal feature in `apps/portal/src/features/{feature}/`
9. [ ] Add hooks, services, and components
10. [ ] Create page in `apps/portal/src/app/`
---
## 🛠️ Development Commands
```bash
# Development
pnpm dev # Start all apps
pnpm dev:bff # Start BFF only
pnpm dev:portal # Start Portal only
# Type checking
pnpm typecheck # Check all packages
pnpm lint # Run ESLint
# Database
pnpm db:migrate # Run migrations
pnpm db:generate # Generate Prisma client
```
---
**Last Updated**: December 2025
- `docs/development/domain/import-hygiene.md`
- `docs/development/bff/integration-patterns.md`
- `docs/development/portal/architecture.md`

View File

@ -33,7 +33,7 @@ import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js";
import { BillingModule } from "@bff/modules/billing/billing.module.js";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SupportModule } from "@bff/modules/support/support.module.js";
@ -87,7 +87,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
MappingsModule,
ServicesModule,
OrdersModule,
InvoicesModule,
BillingModule,
SubscriptionsModule,
CurrencyModule,
SupportModule,

View File

@ -5,7 +5,7 @@ import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js";
import { BillingModule } from "@bff/modules/billing/billing.module.js";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SecurityModule } from "@bff/core/security/security.module.js";
@ -24,7 +24,7 @@ export const apiRoutes: Routes = [
{ path: "", module: MappingsModule },
{ path: "", module: ServicesModule },
{ path: "", module: OrdersModule },
{ path: "", module: InvoicesModule },
{ path: "", module: BillingModule },
{ path: "", module: SubscriptionsModule },
{ path: "", module: CurrencyModule },
{ path: "", module: SupportModule },

View File

@ -1,11 +0,0 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "@bff/infra/database/prisma.module.js";
import { TransactionService } from "./services/transaction.service.js";
import { DistributedTransactionService } from "./services/distributed-transaction.service.js";
@Module({
imports: [PrismaModule],
providers: [TransactionService, DistributedTransactionService],
exports: [TransactionService, DistributedTransactionService],
})
export class DatabaseModule {}

View File

@ -1,6 +1,6 @@
import { Controller, Get } from "@nestjs/common";
import { WhmcsRequestQueueService } from "@bff/core/queue/services/whmcs-request-queue.service.js";
import { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service.js";
import { WhmcsRequestQueueService } from "@bff/infra/queue/services/whmcs-request-queue.service.js";
import { SalesforceRequestQueueService } from "@bff/infra/queue/services/salesforce-request-queue.service.js";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
@Controller("health/queues")

View File

@ -1,11 +0,0 @@
import { Module } from "@nestjs/common";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { WhmcsRequestQueueService } from "./services/whmcs-request-queue.service.js";
import { SalesforceRequestQueueService } from "./services/salesforce-request-queue.service.js";
@Module({
imports: [CacheModule],
providers: [WhmcsRequestQueueService, SalesforceRequestQueueService],
exports: [WhmcsRequestQueueService, SalesforceRequestQueueService],
})
export class QueueModule {}

View File

@ -129,16 +129,16 @@ export class CsrfService {
return { isValid: true };
}
invalidateToken(_token: string): void {
invalidateToken(): void {
// Stateless tokens are tied to the secret cookie; rotate cookie to invalidate.
this.logger.debug("invalidateToken called for stateless CSRF token");
}
invalidateSessionTokens(_sessionId: string): void {
invalidateSessionTokens(): void {
this.logger.debug("invalidateSessionTokens called - rotate cookie to enforce");
}
invalidateUserTokens(_userId: string): void {
invalidateUserTokens(): void {
this.logger.debug("invalidateUserTokens called - rotate cookie to enforce");
}

View File

@ -159,8 +159,7 @@ export function createDeferredPromise<T>(): {
// Use native Promise.withResolvers if available (ES2024)
if (
"withResolvers" in Promise &&
typeof (Promise as unknown as { withResolvers?: <_U>() => unknown }).withResolvers ===
"function"
typeof (Promise as unknown as { withResolvers?: () => unknown }).withResolvers === "function"
) {
return (
Promise as unknown as {

View File

@ -1,9 +1,11 @@
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service.js";
import { TransactionService } from "./services/transaction.service.js";
import { DistributedTransactionService } from "./services/distributed-transaction.service.js";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
providers: [PrismaService, TransactionService, DistributedTransactionService],
exports: [PrismaService, TransactionService, DistributedTransactionService],
})
export class PrismaModule {}

View File

@ -0,0 +1,5 @@
/**
* Database Services
*/
export * from "./transaction.service.js";
export * from "./distributed-transaction.service.js";

View File

@ -1,7 +1,7 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Prisma } from "@prisma/client";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { PrismaService } from "../prisma.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
export interface TransactionContext {
@ -223,7 +223,7 @@ export class TransactionService {
operation: SimpleTransactionOperation<T>,
options: Omit<TransactionOptions, "autoRollback"> = {}
): Promise<T> {
const result = await this.executeTransaction(async (tx, _context) => operation(tx), {
const result = await this.executeTransaction(async tx => operation(tx), {
...options,
autoRollback: false,
});

View File

@ -8,7 +8,7 @@
*/
import type { IdMapping as PrismaIdMapping } from "@prisma/client";
import type { UserIdMapping } from "@customer-portal/domain/mappings";
import type { UserIdMapping } from "@bff/modules/id-mappings/domain/index.js";
/**
* Maps Prisma IdMapping entity to Domain UserIdMapping type

View File

@ -9,9 +9,9 @@
import type { User as PrismaUser } from "@prisma/client";
import type { UserAuth } from "@customer-portal/domain/customer";
import * as CustomerProviders from "@customer-portal/domain/customer/providers";
import { mapPrismaUserToUserAuth } from "@customer-portal/domain/customer/providers";
type PrismaUserRaw = Parameters<typeof CustomerProviders.Portal.mapPrismaUserToUserAuth>[0];
type PrismaUserRaw = Parameters<typeof mapPrismaUserToUserAuth>[0];
/**
* Maps Prisma User entity to Domain UserAuth type
@ -39,5 +39,5 @@ export function mapPrismaUserToDomain(user: PrismaUser): UserAuth {
};
// Use domain provider mapper
return CustomerProviders.Portal.mapPrismaUserToUserAuth(prismaUserRaw);
return mapPrismaUserToUserAuth(prismaUserRaw);
}

View File

@ -2,6 +2,9 @@ import { Global, Module } from "@nestjs/common";
import { BullModule } from "@nestjs/bullmq";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { QUEUE_NAMES } from "./queue.constants.js";
import { WhmcsRequestQueueService } from "./services/whmcs-request-queue.service.js";
import { SalesforceRequestQueueService } from "./services/salesforce-request-queue.service.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
function parseRedisConnection(redisUrl: string) {
try {
@ -24,6 +27,7 @@ function parseRedisConnection(redisUrl: string) {
@Module({
imports: [
ConfigModule,
CacheModule,
BullModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
@ -37,6 +41,7 @@ function parseRedisConnection(redisUrl: string) {
{ name: QUEUE_NAMES.SIM_MANAGEMENT }
),
],
exports: [BullModule],
providers: [WhmcsRequestQueueService, SalesforceRequestQueueService],
exports: [BullModule, WhmcsRequestQueueService, SalesforceRequestQueueService],
})
export class QueueModule {}

View File

@ -0,0 +1,5 @@
/**
* Queue Services
*/
export * from "./whmcs-request-queue.service.js";
export * from "./salesforce-request-queue.service.js";

View File

@ -7,7 +7,7 @@
*
* Usage:
* ```typescript
* import { ACCOUNT_FIELDS } from "@customer-portal/domain/salesforce";
* import { ACCOUNT_FIELDS } from "@bff/integrations/salesforce/constants";
*
* const eligibilityValue = account[ACCOUNT_FIELDS.eligibility.value];
* ```

View File

@ -1,5 +1,5 @@
/**
* Salesforce Domain
* Salesforce Constants
*
* Centralized Salesforce field maps and constants.
* Provides a single source of truth for field names used across

View File

@ -2,7 +2,7 @@ import { Inject, Injectable, HttpException, HttpStatus } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
import { Logger } from "nestjs-pino";
import { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service.js";
import { SalesforceRequestQueueService } from "@bff/infra/queue/services/salesforce-request-queue.service.js";
@Injectable()
export class SalesforceReadThrottleGuard implements CanActivate {

View File

@ -2,7 +2,7 @@ import { Inject, Injectable, HttpException, HttpStatus } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
import { Logger } from "nestjs-pino";
import { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service.js";
import { SalesforceRequestQueueService } from "@bff/infra/queue/services/salesforce-request-queue.service.js";
@Injectable()
export class SalesforceWriteThrottleGuard implements CanActivate {

View File

@ -1,6 +1,6 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { QueueModule } from "@bff/core/queue/queue.module.js";
import { QueueModule } from "@bff/infra/queue/queue.module.js";
import { SalesforceService } from "./salesforce.service.js";
import { SalesforceConnection } from "./services/salesforce-connection.service.js";
import { SalesforceAccountService } from "./services/salesforce-account.service.js";

View File

@ -22,10 +22,14 @@ import {
SALESFORCE_CASE_STATUS,
SALESFORCE_CASE_PRIORITY,
} from "@customer-portal/domain/support/providers";
import * as Providers from "@customer-portal/domain/support/providers";
// Access the mapper directly to avoid unbound method issues
const salesforceMapper = Providers.Salesforce;
import {
buildCaseByIdQuery,
buildCaseSelectFields,
buildCasesForAccountQuery,
toSalesforcePriority,
transformSalesforceCaseToSupportCase,
transformSalesforceCasesToSupportCases,
} from "@customer-portal/domain/support/providers";
/**
* Parameters for creating a case in Salesforce
@ -52,10 +56,7 @@ export class SalesforceCaseService {
const safeAccountId = assertSalesforceId(accountId, "accountId");
this.logger.debug({ accountId: safeAccountId }, "Fetching portal cases for account");
const soql = Providers.Salesforce.buildCasesForAccountQuery(
safeAccountId,
SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE
);
const soql = buildCasesForAccountQuery(safeAccountId, SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE);
try {
const result = (await this.sf.query(soql, {
@ -69,7 +70,7 @@ export class SalesforceCaseService {
"Portal cases retrieved for account"
);
return Providers.Salesforce.transformSalesforceCasesToSupportCases(cases);
return transformSalesforceCasesToSupportCases(cases);
} catch (error: unknown) {
this.logger.error("Failed to fetch cases for account", {
error: getErrorMessage(error),
@ -88,7 +89,7 @@ export class SalesforceCaseService {
this.logger.debug({ caseId: safeCaseId, accountId: safeAccountId }, "Fetching case by ID");
const soql = Providers.Salesforce.buildCaseByIdQuery(
const soql = buildCaseByIdQuery(
safeCaseId,
safeAccountId,
SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE
@ -106,7 +107,7 @@ export class SalesforceCaseService {
return null;
}
return Providers.Salesforce.transformSalesforceCaseToSupportCase(record);
return transformSalesforceCaseToSupportCase(record);
} catch (error: unknown) {
this.logger.error("Failed to fetch case by ID", {
error: getErrorMessage(error),
@ -133,7 +134,7 @@ export class SalesforceCaseService {
// Build case payload with portal defaults
// Convert portal display values to Salesforce API values
const sfPriority = params.priority
? salesforceMapper.toSalesforcePriority(params.priority)
? toSalesforcePriority(params.priority)
: SALESFORCE_CASE_PRIORITY.MEDIUM;
const casePayload: Record<string, unknown> = {
@ -243,7 +244,7 @@ export class SalesforceCaseService {
private async getCaseByIdInternal(caseId: string): Promise<SalesforceCaseRecord | null> {
const safeCaseId = assertSalesforceId(caseId, "caseId");
const fields = Providers.Salesforce.buildCaseSelectFields().join(", ");
const fields = buildCaseSelectFields().join(", ");
const soql = `
SELECT ${fields}
FROM Case

View File

@ -2,7 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service.js";
import { SalesforceRequestQueueService } from "@bff/infra/queue/services/salesforce-request-queue.service.js";
import jsforce from "jsforce";
import { SignJWT } from "jose";
import { createPrivateKey } from "node:crypto";

View File

@ -21,7 +21,10 @@ import type {
SalesforceOrderItemRecord,
SalesforceOrderRecord,
} from "@customer-portal/domain/orders/providers";
import * as OrderProviders from "@customer-portal/domain/orders/providers";
import {
transformSalesforceOrderDetails,
transformSalesforceOrderSummary,
} from "@customer-portal/domain/orders/providers";
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
import { OrderFieldMapService } from "@bff/modules/orders/config/order-field-map.service.js";
@ -98,11 +101,7 @@ export class SalesforceOrderService {
);
// Use domain mapper - single transformation!
return OrderProviders.Salesforce.transformSalesforceOrderDetails(
order,
orderItems,
this.orderFieldMap.fields
);
return transformSalesforceOrderDetails(order, orderItems, this.orderFieldMap.fields);
} catch (error: unknown) {
this.logger.error("Failed to fetch order with items", {
error: getErrorMessage(error),
@ -297,7 +296,7 @@ export class SalesforceOrderService {
(order): order is SalesforceOrderRecord & { Id: string } => typeof order.Id === "string"
)
.map(order =>
OrderProviders.Salesforce.transformSalesforceOrderSummary(
transformSalesforceOrderSummary(
order,
itemsByOrder[order.Id] ?? [],
this.orderFieldMap.fields

View File

@ -89,7 +89,7 @@ export class WhmcsCacheService {
status?: string
): Promise<InvoiceList | null> {
const key = this.buildInvoicesKey(userId, page, limit, status);
return this.get<InvoiceList>(key, "invoices");
return this.get<InvoiceList>(key);
}
/**
@ -103,7 +103,7 @@ export class WhmcsCacheService {
data: InvoiceList
): Promise<void> {
const key = this.buildInvoicesKey(userId, page, limit, status);
await this.set(key, data, "invoices", [`user:${userId}`]);
await this.set(key, data, "invoices");
}
/**
@ -111,7 +111,7 @@ export class WhmcsCacheService {
*/
async getInvoice(userId: string, invoiceId: number): Promise<Invoice | null> {
const key = this.buildInvoiceKey(userId, invoiceId);
return this.get<Invoice>(key, "invoice");
return this.get<Invoice>(key);
}
/**
@ -119,7 +119,7 @@ export class WhmcsCacheService {
*/
async setInvoice(userId: string, invoiceId: number, data: Invoice): Promise<void> {
const key = this.buildInvoiceKey(userId, invoiceId);
await this.set(key, data, "invoice", [`user:${userId}`, `invoice:${invoiceId}`]);
await this.set(key, data, "invoice");
}
/**
@ -127,7 +127,7 @@ export class WhmcsCacheService {
*/
async getSubscriptionsList(userId: string): Promise<SubscriptionList | null> {
const key = this.buildSubscriptionsKey(userId);
return this.get<SubscriptionList>(key, "subscriptions");
return this.get<SubscriptionList>(key);
}
/**
@ -135,7 +135,7 @@ export class WhmcsCacheService {
*/
async setSubscriptionsList(userId: string, data: SubscriptionList): Promise<void> {
const key = this.buildSubscriptionsKey(userId);
await this.set(key, data, "subscriptions", [`user:${userId}`]);
await this.set(key, data, "subscriptions");
}
/**
@ -143,7 +143,7 @@ export class WhmcsCacheService {
*/
async getSubscription(userId: string, subscriptionId: number): Promise<Subscription | null> {
const key = this.buildSubscriptionKey(userId, subscriptionId);
return this.get<Subscription>(key, "subscription");
return this.get<Subscription>(key);
}
/**
@ -151,7 +151,7 @@ export class WhmcsCacheService {
*/
async setSubscription(userId: string, subscriptionId: number, data: Subscription): Promise<void> {
const key = this.buildSubscriptionKey(userId, subscriptionId);
await this.set(key, data, "subscription", [`user:${userId}`, `subscription:${subscriptionId}`]);
await this.set(key, data, "subscription");
}
/**
@ -164,7 +164,7 @@ export class WhmcsCacheService {
limit: number
): Promise<InvoiceList | null> {
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit);
return this.get<InvoiceList>(key, "subscriptionInvoices");
return this.get<InvoiceList>(key);
}
/**
@ -178,10 +178,7 @@ export class WhmcsCacheService {
data: InvoiceList
): Promise<void> {
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit);
await this.set(key, data, "subscriptionInvoices", [
`user:${userId}`,
`subscription:${subscriptionId}`,
]);
await this.set(key, data, "subscriptionInvoices");
}
/**
@ -190,7 +187,7 @@ export class WhmcsCacheService {
*/
async getClientData(clientId: number): Promise<WhmcsClient | null> {
const key = this.buildClientKey(clientId);
return this.get<WhmcsClient>(key, "client");
return this.get<WhmcsClient>(key);
}
/**
@ -198,7 +195,7 @@ export class WhmcsCacheService {
*/
async setClientData(clientId: number, data: WhmcsClient) {
const key = this.buildClientKey(clientId);
await this.set(key, data, "client", [`client:${clientId}`]);
await this.set(key, data, "client");
}
/**
@ -206,7 +203,7 @@ export class WhmcsCacheService {
*/
async getClientIdByEmail(email: string): Promise<number | null> {
const key = this.buildClientEmailKey(email);
return this.get<number>(key, "clientEmail");
return this.get<number>(key);
}
/**
@ -324,7 +321,7 @@ export class WhmcsCacheService {
*/
async getPaymentMethods(userId: string): Promise<PaymentMethodList | null> {
const key = this.buildPaymentMethodsKey(userId);
return this.get<PaymentMethodList>(key, "paymentMethods");
return this.get<PaymentMethodList>(key);
}
/**
@ -332,7 +329,7 @@ export class WhmcsCacheService {
*/
async setPaymentMethods(userId: string, paymentMethods: PaymentMethodList): Promise<void> {
const key = this.buildPaymentMethodsKey(userId);
await this.set(key, paymentMethods, "paymentMethods", [userId]);
await this.set(key, paymentMethods, "paymentMethods");
}
/**
@ -340,7 +337,7 @@ export class WhmcsCacheService {
*/
async getPaymentGateways(): Promise<PaymentGatewayList | null> {
const key = "whmcs:paymentgateways:global";
return this.get<PaymentGatewayList>(key, "paymentGateways");
return this.get<PaymentGatewayList>(key);
}
/**
@ -380,7 +377,7 @@ export class WhmcsCacheService {
/**
* Generic get method with configuration
*/
private async get<T>(key: string, _configKey: string): Promise<T | null> {
private async get<T>(key: string): Promise<T | null> {
try {
const data = await this.cacheService.get<T>(key);
if (data) {
@ -396,14 +393,9 @@ export class WhmcsCacheService {
/**
* Generic set method with configuration
*/
private async set<T>(
key: string,
data: T,
_configKey: string,
_additionalTags: string[] = []
): Promise<void> {
private async set<T>(key: string, data: T, configKey: string): Promise<void> {
try {
const config = this.cacheConfigs[_configKey];
const config = this.cacheConfigs[configKey];
await this.cacheService.set(key, data, config.ttl);
this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`);
} catch (error) {

View File

@ -5,7 +5,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsConfigService } from "../config/whmcs-config.service.js";
import { WhmcsHttpClientService } from "./whmcs-http-client.service.js";
import { WhmcsErrorHandlerService } from "./whmcs-error-handler.service.js";
import { WhmcsRequestQueueService } from "@bff/core/queue/services/whmcs-request-queue.service.js";
import { WhmcsRequestQueueService } from "@bff/infra/queue/services/whmcs-request-queue.service.js";
import type {
WhmcsAddClientParams,
WhmcsValidateLoginParams,
@ -104,7 +104,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
}
// Handle general request errors
this.errorHandler.handleRequestError(error, action, params);
this.errorHandler.handleRequestError(error);
}
},
{
@ -139,7 +139,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
if (this.isHandledException(error)) {
throw error;
}
this.errorHandler.handleRequestError(error, action, params);
this.errorHandler.handleRequestError(error);
}
});
}

View File

@ -28,7 +28,7 @@ export class WhmcsErrorHandlerService {
/**
* Handle general request errors (network, timeout, etc.)
*/
handleRequestError(error: unknown, _action: string, _params: Record<string, unknown>): never {
handleRequestError(error: unknown): never {
if (this.isTimeoutError(error)) {
throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT);
}

View File

@ -3,7 +3,7 @@ import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
import * as CustomerProviders from "@customer-portal/domain/customer/providers";
import { transformWhmcsClientResponse } from "@customer-portal/domain/customer/providers";
import type { WhmcsClient } from "@customer-portal/domain/customer";
/**
@ -43,7 +43,7 @@ export class WhmcsAccountDiscoveryService {
return null;
}
const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response);
const client = transformWhmcsClientResponse(response);
// 3. Cache both the data and the mapping
await Promise.all([
@ -86,7 +86,7 @@ export class WhmcsAccountDiscoveryService {
throw new NotFoundException(`Client ${clientId} not found`);
}
const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response);
const client = transformWhmcsClientResponse(response);
await this.cacheService.setClientData(client.id, client);
return client;
}

View File

@ -10,7 +10,7 @@ import type {
WhmcsAddClientResponse,
WhmcsValidateLoginResponse,
} from "@customer-portal/domain/customer/providers";
import * as CustomerProviders from "@customer-portal/domain/customer/providers";
import { transformWhmcsClientResponse } from "@customer-portal/domain/customer/providers";
import type { WhmcsClient } from "@customer-portal/domain/customer";
@Injectable()
@ -73,7 +73,7 @@ export class WhmcsClientService {
throw new NotFoundException(`Client ${clientId} not found`);
}
const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response);
const client = transformWhmcsClientResponse(response);
await this.cacheService.setClientData(client.id, client);
this.logger.log(`Fetched client details for client ${clientId}`);

View File

@ -4,7 +4,7 @@ import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import { invoiceListSchema, invoiceSchema } from "@customer-portal/domain/billing";
import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
import * as Providers from "@customer-portal/domain/billing/providers";
import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsCurrencyService } from "./whmcs-currency.service.js";
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
@ -165,7 +165,7 @@ export class WhmcsInvoiceService {
// Transform invoice using domain mapper
const defaultCurrency = this.currencyService.getDefaultCurrency();
const invoice = Providers.Whmcs.transformWhmcsInvoice(response, {
const invoice = transformWhmcsInvoice(response, {
defaultCurrencyCode: defaultCurrency.code,
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
});
@ -224,7 +224,7 @@ export class WhmcsInvoiceService {
try {
// Transform using domain mapper
const defaultCurrency = this.currencyService.getDefaultCurrency();
const transformed = Providers.Whmcs.transformWhmcsInvoice(whmcsInvoice, {
const transformed = transformWhmcsInvoice(whmcsInvoice, {
defaultCurrencyCode: defaultCurrency.code,
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
});

View File

@ -10,7 +10,7 @@ import type {
WhmcsAddOrderResponse,
WhmcsOrderResult,
} from "@customer-portal/domain/orders/providers";
import * as Providers from "@customer-portal/domain/orders/providers";
import { buildWhmcsAddOrderPayload } from "@customer-portal/domain/orders/providers";
import {
whmcsAddOrderResponseSchema,
whmcsAcceptOrderResponseSchema,
@ -226,7 +226,7 @@ export class WhmcsOrderService {
* Delegates to shared mapper function from integration package
*/
private buildAddOrderPayload(params: WhmcsAddOrderParams): Record<string, unknown> {
const payload = Providers.Whmcs.buildWhmcsAddOrderPayload(params);
const payload = buildWhmcsAddOrderPayload(params);
this.logger.debug("Built WHMCS AddOrder payload", {
clientId: params.clientId,

View File

@ -1,7 +1,10 @@
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { Logger } from "nestjs-pino";
import { Injectable, Inject } from "@nestjs/common";
import * as Providers from "@customer-portal/domain/payments/providers";
import {
transformWhmcsPaymentGateway,
transformWhmcsPaymentMethod,
} from "@customer-portal/domain/payments/providers";
import type {
PaymentMethodList,
PaymentGateway,
@ -9,7 +12,7 @@ import type {
PaymentMethod,
} from "@customer-portal/domain/payments";
import type { WhmcsCatalogProductNormalized } from "@customer-portal/domain/services/providers";
import * as CatalogProviders from "@customer-portal/domain/services/providers";
import { transformWhmcsCatalogProductsResponse } from "@customer-portal/domain/services/providers";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer/providers";
@ -61,7 +64,7 @@ export class WhmcsPaymentService {
let methods = paymentMethodsArray
.map((pm: WhmcsPaymentMethod) => {
try {
return Providers.Whmcs.transformWhmcsPaymentMethod(pm);
return transformWhmcsPaymentMethod(pm);
} catch (error) {
this.logger.error(`Failed to transform payment method`, {
error: getErrorMessage(error),
@ -136,7 +139,7 @@ export class WhmcsPaymentService {
const gateways = response.gateways.gateway
.map((whmcsGateway: WhmcsPaymentGateway) => {
try {
return Providers.Whmcs.transformWhmcsPaymentGateway(whmcsGateway);
return transformWhmcsPaymentGateway(whmcsGateway);
} catch (error) {
this.logger.error(`Failed to transform payment gateway ${whmcsGateway.name}`, {
error: getErrorMessage(error),
@ -225,7 +228,7 @@ export class WhmcsPaymentService {
async getProducts(): Promise<WhmcsCatalogProductNormalized[]> {
try {
const response = await this.connectionService.getCatalogProducts();
return CatalogProviders.Whmcs.transformWhmcsCatalogProductsResponse(response);
return transformWhmcsCatalogProductsResponse(response);
} catch (error) {
this.logger.error("Failed to get products", {
error: getErrorMessage(error),

View File

@ -2,7 +2,10 @@ import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { Logger } from "nestjs-pino";
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import * as Providers from "@customer-portal/domain/subscriptions/providers";
import {
filterSubscriptionsByStatus,
transformWhmcsSubscriptionListResponse,
} from "@customer-portal/domain/subscriptions/providers";
import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsCurrencyService } from "./whmcs-currency.service.js";
@ -38,7 +41,7 @@ export class WhmcsSubscriptionService {
// Apply status filter if needed
if (filters.status) {
return Providers.Whmcs.filterSubscriptionsByStatus(cached, filters.status);
return filterSubscriptionsByStatus(cached, filters.status);
}
return cached;
@ -65,7 +68,7 @@ export class WhmcsSubscriptionService {
const defaultCurrency = this.currencyService.getDefaultCurrency();
let result: SubscriptionList;
try {
result = Providers.Whmcs.transformWhmcsSubscriptionListResponse(rawResponse, {
result = transformWhmcsSubscriptionListResponse(rawResponse, {
defaultCurrencyCode: defaultCurrency.code,
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
onItemError: (error, product) => {
@ -92,7 +95,7 @@ export class WhmcsSubscriptionService {
// Apply status filter if needed
if (filters.status) {
return Providers.Whmcs.filterSubscriptionsByStatus(result, filters.status);
return filterSubscriptionsByStatus(result, filters.status);
}
return result;
@ -151,7 +154,7 @@ export class WhmcsSubscriptionService {
// Transform response
const defaultCurrency = this.currencyService.getDefaultCurrency();
const resultList = Providers.Whmcs.transformWhmcsSubscriptionListResponse(rawResponse, {
const resultList = transformWhmcsSubscriptionListResponse(rawResponse, {
defaultCurrencyCode: defaultCurrency.code,
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
});

View File

@ -1,6 +1,6 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { QueueModule } from "@bff/core/queue/queue.module.js";
import { QueueModule } from "@bff/infra/queue/queue.module.js";
import { WhmcsCacheService } from "./cache/whmcs-cache.service.js";
import { WhmcsService } from "./whmcs.service.js";
import { WhmcsInvoiceService } from "./services/whmcs-invoice.service.js";

View File

@ -3,7 +3,7 @@ import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
import type { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments";
import { addressSchema, type Address, type WhmcsClient } from "@customer-portal/domain/customer";
import * as CustomerProviders from "@customer-portal/domain/customer/providers";
import { prepareWhmcsClientAddressUpdate } from "@customer-portal/domain/customer/providers";
import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsInvoiceService } from "./services/whmcs-invoice.service.js";
import type { InvoiceFilters } from "./services/whmcs-invoice.service.js";
@ -151,7 +151,8 @@ export class WhmcsService {
}
async updateClientAddress(clientId: number, address: Partial<Address>): Promise<void> {
const updateData = CustomerProviders.Whmcs.prepareWhmcsClientAddressUpdate(address);
const parsed = addressSchema.partial().parse(address ?? {});
const updateData = prepareWhmcsClientAddressUpdate(parsed);
if (Object.keys(updateData).length === 0) return;
await this.clientService.updateClient(clientId, updateData);
}

View File

@ -19,7 +19,7 @@ export class TokenBlacklistService {
this.failClosed = this.configService.get("AUTH_BLACKLIST_FAIL_CLOSED", "false") === "true";
}
async blacklistToken(token: string, _expiresIn?: number): Promise<void> {
async blacklistToken(token: string): Promise<void> {
// Validate token format first
if (!token || typeof token !== "string" || token.split(".").length !== 3) {
this.logger.warn("Invalid token format provided for blacklisting");

View File

@ -27,9 +27,8 @@ import {
type ValidateSignupRequest,
} from "@customer-portal/domain/auth";
import { ErrorCode } from "@customer-portal/domain/common";
import * as CustomerProviders from "@customer-portal/domain/customer/providers";
import { serializeWhmcsKeyValueMap } from "@customer-portal/domain/customer/providers";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import type { User as PrismaUser } from "@prisma/client";
import { CacheService } from "@bff/infra/cache/cache.service.js";
import {
PORTAL_SOURCE_NEW_SIGNUP,
@ -38,11 +37,6 @@ import {
} from "@bff/modules/auth/constants/portal.constants.js";
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
type _SanitizedPrismaUser = Omit<
PrismaUser,
"passwordHash" | "failedLoginAttempts" | "lockedUntil"
>;
interface SignupAccountSnapshot {
id: string;
Name?: string | null;
@ -358,8 +352,7 @@ export class SignupWorkflowService {
postcode: address.postcode,
country: address.country,
password2: password,
customfields:
CustomerProviders.Whmcs.serializeWhmcsKeyValueMap(customfieldsMap) || undefined,
customfields: serializeWhmcsKeyValueMap(customfieldsMap) || undefined,
});
this.logger.log("WHMCS client created successfully", {

View File

@ -14,7 +14,7 @@ import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/w
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import * as CustomerProviders from "@customer-portal/domain/customer/providers";
import { getCustomFieldValue } from "@customer-portal/domain/customer/providers";
import type { User } from "@customer-portal/domain/customer";
import {
PORTAL_SOURCE_MIGRATED,
@ -115,11 +115,8 @@ export class WhmcsLinkWorkflowService {
}
const customerNumber =
CustomerProviders.Whmcs.getCustomFieldValue(clientDetails.customfields, "198")?.trim() ??
CustomerProviders.Whmcs.getCustomFieldValue(
clientDetails.customfields,
"Customer Number"
)?.trim();
getCustomFieldValue(clientDetails.customfields, "198")?.trim() ??
getCustomFieldValue(clientDetails.customfields, "Customer Number")?.trim();
if (!customerNumber) {
throw new BadRequestException(

View File

@ -231,7 +231,7 @@ export class AuthController {
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
@ZodResponse({ status: 200, description: "Migrate/link account", type: LinkWhmcsResponseDto })
async migrateAccount(@Body() linkData: LinkWhmcsRequestDto, @Req() _req: Request) {
async migrateAccount(@Body() linkData: LinkWhmcsRequestDto) {
const result = await this.authFacade.linkWhmcsUser(linkData);
return result;
}

View File

@ -39,13 +39,13 @@ class PaymentGatewayListDto extends createZodDto(paymentGatewayListSchema) {}
class InvoicePaymentLinkDto extends createZodDto(invoicePaymentLinkSchema) {}
/**
* Invoice Controller
* Billing Controller
*
* All request validation is handled by Zod schemas via global ZodValidationPipe.
* Business logic is delegated to service layer.
*/
@Controller("invoices")
export class InvoicesController {
export class BillingController {
constructor(
private readonly invoicesService: InvoicesOrchestratorService,
private readonly whmcsService: WhmcsService,
@ -102,10 +102,7 @@ export class InvoicesController {
}
@Get(":id/subscriptions")
getInvoiceSubscriptions(
@Request() _req: RequestWithUser,
@Param() _params: InvoiceIdParamDto
): Subscription[] {
getInvoiceSubscriptions(): Subscription[] {
// This functionality has been moved to WHMCS directly
// For now, return empty array as subscriptions are managed in WHMCS
return [];

View File

@ -1,5 +1,5 @@
import { Module } from "@nestjs/common";
import { InvoicesController } from "./invoices.controller.js";
import { BillingController } from "./billing.controller.js";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
// New modular invoice services
@ -8,15 +8,15 @@ import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js
import { InvoiceHealthService } from "./services/invoice-health.service.js";
/**
* Invoice Module
* Billing Module
*
* Validation is handled by Zod schemas via Zod DTOs + the global ZodValidationPipe (APP_PIPE).
* No separate validator service needed.
*/
@Module({
imports: [WhmcsModule, MappingsModule],
controllers: [InvoicesController],
controllers: [BillingController],
providers: [InvoicesOrchestratorService, InvoiceRetrievalService, InvoiceHealthService],
exports: [InvoicesOrchestratorService],
})
export class InvoicesModule {}
export class BillingModule {}

View File

@ -1,9 +1,9 @@
/**
* Invoice Module Exports
* Billing Module Exports
*/
export * from "./invoices.module.js";
export * from "./invoices.controller.js";
export * from "./billing.module.js";
export * from "./billing.controller.js";
export * from "./services/invoices-orchestrator.service.js";
export * from "./services/invoice-retrieval.service.js";
export * from "./services/invoice-health.service.js";

View File

@ -2,7 +2,7 @@ import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller.js";
import { PrismaModule } from "@bff/infra/database/prisma.module.js";
import { ConfigModule } from "@nestjs/config";
import { QueueModule } from "@bff/core/queue/queue.module.js";
import { QueueModule } from "@bff/infra/queue/queue.module.js";
import { QueueHealthController } from "@bff/core/health/queue-health.controller.js";
@Module({

View File

@ -1,7 +1,7 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { CacheService } from "@bff/infra/cache/cache.service.js";
import type { UserIdMapping } from "@customer-portal/domain/mappings";
import type { UserIdMapping } from "../domain/index.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
@Injectable()

View File

@ -4,7 +4,7 @@
* Normalized types for mapping portal users to external systems.
*/
import type { IsoDateTimeString } from "../common/types.js";
import type { IsoDateTimeString } from "@customer-portal/domain/common";
export interface UserIdMapping {
id: string;

View File

@ -1,5 +1,7 @@
/**
* ID Mapping Domain
*
* Types, schemas, and validation for mapping portal users to external systems.
*/
export * from "./contract.js";

View File

@ -16,7 +16,7 @@ import type {
UpdateMappingRequest,
MappingSearchFilters,
MappingStats,
} from "@customer-portal/domain/mappings";
} from "./domain/index.js";
import {
createMappingRequestSchema,
updateMappingRequestSchema,
@ -25,7 +25,7 @@ import {
checkMappingCompleteness,
sanitizeCreateRequest,
sanitizeUpdateRequest,
} from "@customer-portal/domain/mappings";
} from "./domain/index.js";
import type { Prisma, IdMapping as PrismaIdMapping } from "@prisma/client";
import { mapPrismaMappingToDomain } from "@bff/infra/mappers/index.js";

View File

@ -1,4 +1,4 @@
import type { UserIdMapping } from "@customer-portal/domain/mappings";
import type { UserIdMapping, MappingValidationResult } from "../domain/index.js";
/**
* BFF-specific mapping types
@ -18,4 +18,4 @@ export interface CachedMapping {
}
// Re-export validation result from domain for backward compatibility
export type { MappingValidationResult } from "@customer-portal/domain/mappings";
export type { MappingValidationResult };

View File

@ -7,7 +7,7 @@ import {
type SalesforceOrderFieldMap,
} from "@customer-portal/domain/orders/providers";
const unique = <T>(values: T[]): T[] => Array.from(new Set(values));
const unique = (values: string[]): string[] => Array.from(new Set(values));
const SECTION_PREFIX: Record<keyof SalesforceOrderFieldMap, string> = {
order: "ORDER",

View File

@ -5,7 +5,6 @@ import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { UsersModule } from "@bff/modules/users/users.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js";
import { DatabaseModule } from "@bff/core/database/database.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
@ -38,7 +37,6 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
MappingsModule,
UsersModule,
CoreConfigModule,
DatabaseModule,
ServicesModule,
CacheModule,
VerificationModule,

View File

@ -49,7 +49,7 @@ export class OrderBuilder {
this.addSimFields(orderFields, body, orderFieldNames);
break;
case "VPN":
this.addVpnFields(orderFields, body);
this.addVpnFields();
break;
}
@ -111,10 +111,7 @@ export class OrderBuilder {
}
}
private addVpnFields(
_orderFields: Record<string, unknown>,
_body: OrderBusinessValidation
): void {
private addVpnFields(): void {
// No additional fields for VPN orders at this time.
}

View File

@ -8,7 +8,7 @@ import { OrderOrchestrator } from "./order-orchestrator.service.js";
import { OrderFulfillmentValidator } from "./order-fulfillment-validator.service.js";
import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service.js";
import { SimFulfillmentService } from "./sim-fulfillment.service.js";
import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service.js";
import { DistributedTransactionService } from "@bff/infra/database/services/distributed-transaction.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { OrderEventsService } from "./order-events.service.js";
import { OrdersCacheService } from "./orders-cache.service.js";
@ -16,7 +16,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import type { OrderDetails } from "@customer-portal/domain/orders";
import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers";
import * as OrderProviders from "@customer-portal/domain/orders/providers";
import { createOrderNotes, mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers";
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
@ -26,7 +26,7 @@ import {
WhmcsOperationException,
} from "@bff/core/exceptions/domain-exceptions.js";
type WhmcsOrderItemMappingResult = ReturnType<typeof OrderProviders.Whmcs.mapOrderToWhmcsItems>;
type WhmcsOrderItemMappingResult = ReturnType<typeof mapOrderToWhmcsItems>;
export interface OrderFulfillmentStep {
step: string;
@ -210,7 +210,7 @@ export class OrderFulfillmentOrchestrator {
return Promise.reject(new Error("Order details are required for mapping"));
}
// Use domain mapper directly - single transformation!
const result = OrderProviders.Whmcs.mapOrderToWhmcsItems(context.orderDetails);
const result = mapOrderToWhmcsItems(context.orderDetails);
mappingResult = result;
this.logger.log("OrderItems mapped to WHMCS", {
@ -240,7 +240,7 @@ export class OrderFulfillmentOrchestrator {
});
}
const orderNotes = OrderProviders.Whmcs.createOrderNotes(
const orderNotes = createOrderNotes(
sfOrderId,
`Provisioned from Salesforce Order ${sfOrderId}`
);

View File

@ -11,7 +11,7 @@ import {
} from "@customer-portal/domain/orders";
import type * as Providers from "@customer-portal/domain/subscriptions/providers";
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
type WhmcsProduct = Providers.WhmcsProductRaw;
import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js";
import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js";
@ -227,7 +227,7 @@ export class OrderValidator {
// 3. SKU validation
const pricebookId = await this.pricebookService.findPortalPricebookId();
const _productMeta = await this.validateSKUs(businessValidatedBody.skus, pricebookId);
await this.validateSKUs(businessValidatedBody.skus, pricebookId);
if (businessValidatedBody.orderType === "SIM") {
const verification = await this.residenceCards.getStatusForUser(userId);

View File

@ -54,7 +54,7 @@ export class AccountServicesController {
@Get("vpn/plans")
@RateLimit({ limit: 60, ttl: 60 })
@Header("Cache-Control", "private, no-store")
async getVpnCatalogForAccount(@Request() _req: RequestWithUser): Promise<VpnCatalogCollection> {
async getVpnCatalogForAccount(): Promise<VpnCatalogCollection> {
const catalog = await this.vpnCatalog.getCatalogData();
return parseVpnCatalog(catalog);
}

View File

@ -8,7 +8,7 @@ import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { QueueModule } from "@bff/core/queue/queue.module.js";
import { QueueModule } from "@bff/infra/queue/queue.module.js";
import { BaseServicesService } from "./services/base-services.service.js";
import { InternetServicesService } from "./services/internet-services.service.js";

View File

@ -15,7 +15,7 @@ import type {
SalesforcePricebookEntryRecord,
SalesforceProduct2WithPricebookEntries,
} from "@customer-portal/domain/services/providers";
import * as CatalogProviders from "@customer-portal/domain/services/providers";
import { extractPricebookEntry as extractSalesforcePricebookEntry } from "@customer-portal/domain/services/providers";
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
@Injectable()
@ -55,7 +55,7 @@ export class BaseServicesService {
protected extractPricebookEntry(
record: SalesforceProduct2WithPricebookEntries
): SalesforcePricebookEntryRecord | undefined {
const entry = CatalogProviders.Salesforce.extractPricebookEntry(record);
const entry = extractSalesforcePricebookEntry(record);
if (!entry) {
const sku = record.StockKeepingUnit ?? undefined;
this.logger.warn(

View File

@ -16,7 +16,11 @@ import {
inferInstallationTermFromSku,
internetEligibilityDetailsSchema,
} from "@customer-portal/domain/services";
import * as CatalogProviders from "@customer-portal/domain/services/providers";
import {
mapInternetAddon,
mapInternetInstallation,
mapInternetPlan,
} from "@customer-portal/domain/services/providers";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
@ -63,7 +67,7 @@ export class InternetServicesService extends BaseServicesService {
const plans = records.map(record => {
const entry = this.extractPricebookEntry(record);
const plan = CatalogProviders.Salesforce.mapInternetPlan(record, entry);
const plan = mapInternetPlan(record, entry);
return enrichInternetPlanMetadata(plan);
});
@ -99,7 +103,7 @@ export class InternetServicesService extends BaseServicesService {
return records
.map(record => {
const entry = this.extractPricebookEntry(record);
const installation = CatalogProviders.Salesforce.mapInternetInstallation(record, entry);
const installation = mapInternetInstallation(record, entry);
return {
...installation,
catalogMetadata: {
@ -140,7 +144,7 @@ export class InternetServicesService extends BaseServicesService {
return records
.map(record => {
const entry = this.extractPricebookEntry(record);
const addon = CatalogProviders.Salesforce.mapInternetAddon(record, entry);
const addon = mapInternetAddon(record, entry);
return {
...addon,
catalogMetadata: {

View File

@ -7,7 +7,7 @@ import type {
SimActivationFeeCatalogItem,
} from "@customer-portal/domain/services";
import type { SalesforceProduct2WithPricebookEntries } from "@customer-portal/domain/services/providers";
import * as CatalogProviders from "@customer-portal/domain/services/providers";
import { mapSimActivationFee, mapSimProduct } from "@customer-portal/domain/services/providers";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { Logger } from "nestjs-pino";
@ -45,7 +45,7 @@ export class SimServicesService extends BaseServicesService {
return records.map(record => {
const entry = this.extractPricebookEntry(record);
const product = CatalogProviders.Salesforce.mapSimProduct(record, entry);
const product = mapSimProduct(record, entry);
return {
...product,
@ -76,7 +76,7 @@ export class SimServicesService extends BaseServicesService {
const activationFees = records
.map(record => {
const entry = this.extractPricebookEntry(record);
return CatalogProviders.Salesforce.mapSimActivationFee(record, entry);
return mapSimActivationFee(record, entry);
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
@ -130,7 +130,7 @@ export class SimServicesService extends BaseServicesService {
return records
.map(record => {
const entry = this.extractPricebookEntry(record);
const product = CatalogProviders.Salesforce.mapSimProduct(record, entry);
const product = mapSimProduct(record, entry);
return {
...product,

View File

@ -6,7 +6,7 @@ import { BaseServicesService } from "./base-services.service.js";
import { ServicesCacheService } from "./services-cache.service.js";
import type { VpnCatalogProduct } from "@customer-portal/domain/services";
import type { SalesforceProduct2WithPricebookEntries } from "@customer-portal/domain/services/providers";
import * as CatalogProviders from "@customer-portal/domain/services/providers";
import { mapVpnProduct } from "@customer-portal/domain/services/providers";
@Injectable()
export class VpnServicesService extends BaseServicesService {
@ -32,7 +32,7 @@ export class VpnServicesService extends BaseServicesService {
return records.map(record => {
const entry = this.extractPricebookEntry(record);
const product = CatalogProviders.Salesforce.mapVpnProduct(record, entry);
const product = mapVpnProduct(record, entry);
return {
...product,
description: product.description || product.name,
@ -64,7 +64,7 @@ export class VpnServicesService extends BaseServicesService {
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = CatalogProviders.Salesforce.mapVpnProduct(record, pricebookEntry);
const product = mapVpnProduct(record, pricebookEntry);
return {
...product,

View File

@ -191,7 +191,7 @@ export class SimCallHistoryService {
continue;
}
const [phoneNumber, dateStr, timeStr, sentTo, _callType, smsTypeStr] = columns;
const [phoneNumber, dateStr, timeStr, sentTo, , smsTypeStr] = columns;
// Parse date
const smsDate = this.parseDate(dateStr);

View File

@ -107,13 +107,13 @@ export class SimValidationService {
const expectedEid = "89049032000001000000043598005455";
const foundSimNumber = Object.entries(subscription.customFields || {}).find(
([_key, value]) =>
([, value]) =>
value !== undefined &&
value !== null &&
this.formatCustomFieldValue(value).includes(expectedSimNumber)
);
const eidField = Object.entries(subscription.customFields || {}).find(([_key, value]) => {
const eidField = Object.entries(subscription.customFields || {}).find(([, value]) => {
if (value === undefined || value === null) return false;
return this.formatCustomFieldValue(value).includes(expectedEid);
});

View File

@ -17,7 +17,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { Logger } from "nestjs-pino";
import type * as Providers from "@customer-portal/domain/subscriptions/providers";
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
type WhmcsProduct = Providers.WhmcsProductRaw;
export interface GetSubscriptionsOptions {
status?: SubscriptionStatus;

View File

@ -15,7 +15,10 @@ import {
type Address,
type User,
} from "@customer-portal/domain/customer";
import * as CustomerProviders from "@customer-portal/domain/customer/providers";
import {
getCustomFieldValue,
mapPrismaUserToUserAuth,
} from "@customer-portal/domain/customer/providers";
import {
updateCustomerProfileRequestSchema,
type UpdateCustomerProfileRequest,
@ -149,7 +152,11 @@ export class UserProfileService {
}
// Allow phone/company/language updates through to WHMCS
const { email: _email, firstname: _fn, lastname: _ln, ...whmcsUpdate } = parsed;
// Exclude email/firstname/lastname from WHMCS update (handled separately above or disallowed)
const { email, firstname, lastname, ...whmcsUpdate } = parsed;
void email; // Email is handled above in a separate flow
void firstname; // Name changes are explicitly disallowed
void lastname;
if (Object.keys(whmcsUpdate).length > 0) {
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdate);
}
@ -432,7 +439,7 @@ export class UserProfileService {
try {
const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user);
const userAuth = mapPrismaUserToUserAuth(user);
const base = combineToUser(userAuth, whmcsClient);
// Portal-visible identifiers (read-only). These are stored in WHMCS custom fields.
@ -444,16 +451,13 @@ export class UserProfileService {
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
const rawSfNumber = customerNumberFieldId
? CustomerProviders.Whmcs.getCustomFieldValue(
whmcsClient.customfields,
customerNumberFieldId
)
? getCustomFieldValue(whmcsClient.customfields, customerNumberFieldId)
: undefined;
const rawDob = dobFieldId
? CustomerProviders.Whmcs.getCustomFieldValue(whmcsClient.customfields, dobFieldId)
? getCustomFieldValue(whmcsClient.customfields, dobFieldId)
: undefined;
const rawGender = genderFieldId
? CustomerProviders.Whmcs.getCustomFieldValue(whmcsClient.customfields, genderFieldId)
? getCustomFieldValue(whmcsClient.customfields, genderFieldId)
: undefined;
const sfNumber = rawSfNumber?.trim() ? rawSfNumber.trim() : null;

View File

@ -1,62 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import React, { ReactNode } from "react";
interface AuroraBackgroundProps extends React.HTMLAttributes<HTMLDivElement> {
children: ReactNode;
showRadialGradient?: boolean;
}
export const AuroraBackground = ({
className,
children,
showRadialGradient = true,
...props
}: AuroraBackgroundProps) => {
return (
<main>
<div
className={cn(
"transition-bg relative flex h-[100vh] flex-col items-center justify-center bg-zinc-50 text-slate-950 dark:bg-zinc-900",
className
)}
{...props}
>
<div
className="absolute inset-0 overflow-hidden"
style={
{
"--aurora":
"repeating-linear-gradient(100deg,#3b82f6_10%,#a5b4fc_15%,#93c5fd_20%,#ddd6fe_25%,#60a5fa_30%)",
"--dark-gradient":
"repeating-linear-gradient(100deg,#000_0%,#000_7%,transparent_10%,transparent_12%,#000_16%)",
"--white-gradient":
"repeating-linear-gradient(100deg,#fff_0%,#fff_7%,transparent_10%,transparent_12%,#fff_16%)",
"--blue-300": "#93c5fd",
"--blue-400": "#60a5fa",
"--blue-500": "#3b82f6",
"--indigo-300": "#a5b4fc",
"--violet-200": "#ddd6fe",
"--black": "#000",
"--white": "#fff",
"--transparent": "transparent",
} as React.CSSProperties
}
>
<div
// I'm sorry but this is what peak developer performance looks like // trigger warning
className={cn(
`after:animate-aurora pointer-events-none absolute -inset-[10px] [background-image:var(--white-gradient),var(--aurora)] [background-size:300%,_200%] [background-position:50%_50%,50%_50%] opacity-50 blur-[10px] invert filter will-change-transform [--aurora:repeating-linear-gradient(100deg,var(--blue-500)_10%,var(--indigo-300)_15%,var(--blue-300)_20%,var(--violet-200)_25%,var(--blue-400)_30%)] [--dark-gradient:repeating-linear-gradient(100deg,var(--black)_0%,var(--black)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--black)_16%)] [--white-gradient:repeating-linear-gradient(100deg,var(--white)_0%,var(--white)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--white)_16%)] after:absolute after:inset-0 after:[background-image:var(--white-gradient),var(--aurora)] after:[background-size:200%,_100%] after:[background-attachment:fixed] after:mix-blend-difference after:content-[""] dark:[background-image:var(--dark-gradient),var(--aurora)] dark:invert-0 after:dark:[background-image:var(--dark-gradient),var(--aurora)]`,
showRadialGradient &&
`[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]`
)}
></div>
</div>
{children}
</div>
</main>
);
};

View File

@ -9,7 +9,7 @@ import {
addressFormToRequest,
type AddressFormData,
} from "@customer-portal/domain/customer";
import { useZodForm } from "@/hooks/useZodForm";
import { useZodForm } from "@/lib/hooks/useZodForm";
export function useAddressEdit(initial: AddressFormData) {
const queryClient = useQueryClient();

View File

@ -9,7 +9,7 @@ import {
type ProfileEditFormData,
} from "@customer-portal/domain/customer";
import { type UpdateCustomerProfileRequest } from "@customer-portal/domain/auth";
import { useZodForm } from "@/hooks/useZodForm";
import { useZodForm } from "@/lib/hooks/useZodForm";
export function useProfileEdit(initial: ProfileEditFormData) {
const handleSave = useCallback(async (formData: ProfileEditFormData) => {

View File

@ -8,7 +8,7 @@ import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { useWhmcsLink } from "@/features/auth/hooks";
import { linkWhmcsRequestSchema, type LinkWhmcsResponse } from "@customer-portal/domain/auth";
import { useZodForm } from "@/hooks/useZodForm";
import { useZodForm } from "@/lib/hooks/useZodForm";
interface LinkWhmcsFormProps {
onTransferred?: (result: LinkWhmcsResponse) => void;

View File

@ -12,7 +12,7 @@ import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { useLogin } from "../../hooks/use-auth";
import { loginRequestSchema } from "@customer-portal/domain/auth";
import { useZodForm } from "@/hooks/useZodForm";
import { useZodForm } from "@/lib/hooks/useZodForm";
import { z } from "zod";
import { getSafeRedirect } from "@/features/auth/utils/route-protection";

View File

@ -10,7 +10,7 @@ import Link from "next/link";
import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { usePasswordReset } from "../../hooks/use-auth";
import { useZodForm } from "@/hooks/useZodForm";
import { useZodForm } from "@/lib/hooks/useZodForm";
import { passwordResetRequestSchema, passwordResetSchema } from "@customer-portal/domain/auth";
import { z } from "zod";

View File

@ -9,7 +9,7 @@ import Link from "next/link";
import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { useWhmcsLink } from "../../hooks/use-auth";
import { useZodForm } from "@/hooks/useZodForm";
import { useZodForm } from "@/lib/hooks/useZodForm";
import {
setPasswordRequestSchema,
checkPasswordStrength,

View File

@ -12,7 +12,7 @@ import { ErrorMessage } from "@/components/atoms";
import { useSignupWithRedirect } from "../../hooks/use-auth";
import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/auth";
import { addressFormSchema } from "@customer-portal/domain/customer";
import { useZodForm } from "@/hooks/useZodForm";
import { useZodForm } from "@/lib/hooks/useZodForm";
import { z } from "zod";
import { getSafeRedirect } from "@/features/auth/utils/route-protection";

View File

@ -9,7 +9,7 @@ import {
DevicePhoneMobileIcon,
ShieldCheckIcon,
} from "@heroicons/react/24/outline";
import { SimManagementSection } from "@/features/sim-management";
import { SimManagementSection } from "@/features/sim";
interface ServiceManagementSectionProps {
subscriptionId: number;

View File

@ -3,7 +3,7 @@
import { useEffect } from "react";
import { MapPinIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { COUNTRY_OPTIONS, getCountryCodeByName } from "@/lib/constants/countries";
import { useZodForm } from "@/hooks/useZodForm";
import { useZodForm } from "@/lib/hooks/useZodForm";
import {
addressFormSchema,
type AddressFormData,

View File

@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import { useAuthStore } from "@/features/auth/services/auth.store";
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
import type { SimDetails } from "@/features/sim/components/SimDetailsCard";
type Step = 1 | 2 | 3;

View File

@ -9,7 +9,7 @@ import { DevicePhoneMobileIcon, DeviceTabletIcon, CpuChipIcon } from "@heroicons
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import type { SimReissueFullRequest } from "@customer-portal/domain/sim";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
import type { SimDetails } from "@/features/sim/components/SimDetailsCard";
import { Button } from "@/components/atoms";
type SimType = "physical" | "esim";

View File

@ -18,7 +18,7 @@ import { PageLayout } from "@/components/templates/PageLayout";
import { StatusPill } from "@/components/atoms/status-pill";
const { formatCurrency: sharedFormatCurrency } = Formatting;
import { SimManagementSection } from "@/features/sim-management";
import { SimManagementSection } from "@/features/sim";
import {
getBillingCycleLabel,
getSubscriptionStatusVariant,

View File

@ -5,7 +5,7 @@ import Link from "next/link";
import { Button, Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { useZodForm } from "@/hooks/useZodForm";
import { useZodForm } from "@/lib/hooks/useZodForm";
import { Mail, CheckCircle, MapPin } from "lucide-react";
import {
publicContactRequestSchema,

View File

@ -1,3 +1,4 @@
export { useLocalStorage } from "./useLocalStorage";
export { useDebounce } from "./useDebounce";
export { useMediaQuery, useIsMobile, useIsTablet, useIsDesktop } from "./useMediaQuery";
export { useZodForm } from "./useZodForm";

View File

@ -99,7 +99,7 @@ export function mapPrismaUserToDomain(user: PrismaUser): AuthenticatedUser {
```typescript
import type { IdMapping as PrismaIdMapping } from "@prisma/client";
import type { UserIdMapping } from "@customer-portal/domain/mappings";
import type { UserIdMapping } from "@bff/modules/id-mappings/domain/index.js";
/**
* Maps Prisma IdMapping entity to Domain UserIdMapping type

View File

@ -14,6 +14,20 @@
- **BFF-only (integration/infrastructure)**:
- `@customer-portal/domain/<module>/providers`
### Domain-internal helpers (for domain code only)
Sometimes provider helpers need to be shared across multiple domain mappers **without**
becoming part of the public contract (and without enabling deep imports).
In those cases, keep the helpers in a stable internal location (e.g.
`packages/domain/common/providers/whmcs-utils/`) and import them via **relative imports**
from within `packages/domain/**`.
- **Allowed**: inside `packages/domain/**` only
- **Never**: imported from `apps/**`
- **Purpose**: share provider-only helpers (parsing, encoding/serialization, provider quirks)
across multiple domain mappers while keeping the app-facing API surface clean.
## Quick Reference
| Context | Import Pattern | Example |

Some files were not shown because too many files have changed in this diff Show More