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 ### Repo boundaries (non-negotiable)
- Check `docs/development/` for implementation patterns
- Review `docs/architecture/` for system design
- Check `docs/integrations/` for external API details
**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)
``` Examples:
apps/
portal/ # Next.js 15 frontend (React 19)
bff/ # NestJS 11 Backend-for-Frontend
packages/
domain/ # Pure domain types/schemas/utils (isomorphic)
```
### Technology Stack ```ts
// ✅ correct (apps)
- **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!)
import type { Invoice } from "@customer-portal/domain/billing"; import type { Invoice } from "@customer-portal/domain/billing";
import { invoiceSchema } from "@customer-portal/domain/billing";
// Utility imports // ✅ correct (BFF integrations only)
import { apiClient } from "@/lib/api"; 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 ### Portal rules (Next.js)
// ✅ 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(),
});
// ❌ WRONG: Will fail on URL strings - **Pages are wrappers** under `apps/portal/src/app/**` (no API calls in pages)
export const paginationSchema = z.object({ - **Feature modules own logic** under `apps/portal/src/features/<feature>/**`
page: z.number().optional(), // "1" !== 1 - hooks (React Query)
}); - services (API client calls)
``` - components/views (UI composition)
- **No provider imports** from domain in Portal.
### Request Body Validation ### Naming & safety
```typescript - No `any` in public APIs
// In domain schema - Avoid unsafe assertions
export const createOrderRequestSchema = z.object({ - No `console.log` in production code (use logger)
items: z.array(orderItemSchema).min(1), - Avoid `V2` suffix in service names
shippingAddressId: z.string().uuid(),
});
export type CreateOrderRequest = z.infer<typeof createOrderRequestSchema>;
```
### Form Validation (Frontend) ### References
```typescript - `docs/development/domain/import-hygiene.md`
import { useForm } from "react-hook-form"; - `docs/development/bff/integration-patterns.md`
import { zodResolver } from "@hookform/resolvers/zod"; - `docs/development/portal/architecture.md`
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

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 { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js"; import { ServicesModule } from "@bff/modules/services/services.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.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 { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
import { CurrencyModule } from "@bff/modules/currency/currency.module.js"; import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SupportModule } from "@bff/modules/support/support.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, MappingsModule,
ServicesModule, ServicesModule,
OrdersModule, OrdersModule,
InvoicesModule, BillingModule,
SubscriptionsModule, SubscriptionsModule,
CurrencyModule, CurrencyModule,
SupportModule, 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 { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js"; import { ServicesModule } from "@bff/modules/services/services.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.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 { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
import { CurrencyModule } from "@bff/modules/currency/currency.module.js"; import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SecurityModule } from "@bff/core/security/security.module.js"; import { SecurityModule } from "@bff/core/security/security.module.js";
@ -24,7 +24,7 @@ export const apiRoutes: Routes = [
{ path: "", module: MappingsModule }, { path: "", module: MappingsModule },
{ path: "", module: ServicesModule }, { path: "", module: ServicesModule },
{ path: "", module: OrdersModule }, { path: "", module: OrdersModule },
{ path: "", module: InvoicesModule }, { path: "", module: BillingModule },
{ path: "", module: SubscriptionsModule }, { path: "", module: SubscriptionsModule },
{ path: "", module: CurrencyModule }, { path: "", module: CurrencyModule },
{ path: "", module: SupportModule }, { 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 { Controller, Get } from "@nestjs/common";
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 { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-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"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
@Controller("health/queues") @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 }; return { isValid: true };
} }
invalidateToken(_token: string): void { invalidateToken(): void {
// Stateless tokens are tied to the secret cookie; rotate cookie to invalidate. // Stateless tokens are tied to the secret cookie; rotate cookie to invalidate.
this.logger.debug("invalidateToken called for stateless CSRF token"); this.logger.debug("invalidateToken called for stateless CSRF token");
} }
invalidateSessionTokens(_sessionId: string): void { invalidateSessionTokens(): void {
this.logger.debug("invalidateSessionTokens called - rotate cookie to enforce"); this.logger.debug("invalidateSessionTokens called - rotate cookie to enforce");
} }
invalidateUserTokens(_userId: string): void { invalidateUserTokens(): void {
this.logger.debug("invalidateUserTokens called - rotate cookie to enforce"); 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) // Use native Promise.withResolvers if available (ES2024)
if ( if (
"withResolvers" in Promise && "withResolvers" in Promise &&
typeof (Promise as unknown as { withResolvers?: <_U>() => unknown }).withResolvers === typeof (Promise as unknown as { withResolvers?: () => unknown }).withResolvers === "function"
"function"
) { ) {
return ( return (
Promise as unknown as { Promise as unknown as {

View File

@ -1,9 +1,11 @@
import { Global, Module } from "@nestjs/common"; import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service.js"; import { PrismaService } from "./prisma.service.js";
import { TransactionService } from "./services/transaction.service.js";
import { DistributedTransactionService } from "./services/distributed-transaction.service.js";
@Global() @Global()
@Module({ @Module({
providers: [PrismaService], providers: [PrismaService, TransactionService, DistributedTransactionService],
exports: [PrismaService], exports: [PrismaService, TransactionService, DistributedTransactionService],
}) })
export class PrismaModule {} 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 { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { Prisma } from "@prisma/client"; 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"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
export interface TransactionContext { export interface TransactionContext {
@ -223,7 +223,7 @@ export class TransactionService {
operation: SimpleTransactionOperation<T>, operation: SimpleTransactionOperation<T>,
options: Omit<TransactionOptions, "autoRollback"> = {} options: Omit<TransactionOptions, "autoRollback"> = {}
): Promise<T> { ): Promise<T> {
const result = await this.executeTransaction(async (tx, _context) => operation(tx), { const result = await this.executeTransaction(async tx => operation(tx), {
...options, ...options,
autoRollback: false, autoRollback: false,
}); });

View File

@ -8,7 +8,7 @@
*/ */
import type { IdMapping as PrismaIdMapping } from "@prisma/client"; 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 * Maps Prisma IdMapping entity to Domain UserIdMapping type

View File

@ -9,9 +9,9 @@
import type { User as PrismaUser } from "@prisma/client"; import type { User as PrismaUser } from "@prisma/client";
import type { UserAuth } from "@customer-portal/domain/customer"; 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 * Maps Prisma User entity to Domain UserAuth type
@ -39,5 +39,5 @@ export function mapPrismaUserToDomain(user: PrismaUser): UserAuth {
}; };
// Use domain provider mapper // 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 { BullModule } from "@nestjs/bullmq";
import { ConfigModule, ConfigService } from "@nestjs/config"; import { ConfigModule, ConfigService } from "@nestjs/config";
import { QUEUE_NAMES } from "./queue.constants.js"; 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) { function parseRedisConnection(redisUrl: string) {
try { try {
@ -24,6 +27,7 @@ function parseRedisConnection(redisUrl: string) {
@Module({ @Module({
imports: [ imports: [
ConfigModule, ConfigModule,
CacheModule,
BullModule.forRootAsync({ BullModule.forRootAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
@ -37,6 +41,7 @@ function parseRedisConnection(redisUrl: string) {
{ name: QUEUE_NAMES.SIM_MANAGEMENT } { name: QUEUE_NAMES.SIM_MANAGEMENT }
), ),
], ],
exports: [BullModule], providers: [WhmcsRequestQueueService, SalesforceRequestQueueService],
exports: [BullModule, WhmcsRequestQueueService, SalesforceRequestQueueService],
}) })
export class QueueModule {} 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: * Usage:
* ```typescript * ```typescript
* import { ACCOUNT_FIELDS } from "@customer-portal/domain/salesforce"; * import { ACCOUNT_FIELDS } from "@bff/integrations/salesforce/constants";
* *
* const eligibilityValue = account[ACCOUNT_FIELDS.eligibility.value]; * const eligibilityValue = account[ACCOUNT_FIELDS.eligibility.value];
* ``` * ```

View File

@ -1,5 +1,5 @@
/** /**
* Salesforce Domain * Salesforce Constants
* *
* Centralized Salesforce field maps and constants. * Centralized Salesforce field maps and constants.
* Provides a single source of truth for field names used across * 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 { CanActivate, ExecutionContext } from "@nestjs/common";
import type { Request } from "express"; import type { Request } from "express";
import { Logger } from "nestjs-pino"; 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() @Injectable()
export class SalesforceReadThrottleGuard implements CanActivate { 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 { CanActivate, ExecutionContext } from "@nestjs/common";
import type { Request } from "express"; import type { Request } from "express";
import { Logger } from "nestjs-pino"; 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() @Injectable()
export class SalesforceWriteThrottleGuard implements CanActivate { export class SalesforceWriteThrottleGuard implements CanActivate {

View File

@ -1,6 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; 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 { SalesforceService } from "./salesforce.service.js";
import { SalesforceConnection } from "./services/salesforce-connection.service.js"; import { SalesforceConnection } from "./services/salesforce-connection.service.js";
import { SalesforceAccountService } from "./services/salesforce-account.service.js"; import { SalesforceAccountService } from "./services/salesforce-account.service.js";

View File

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

View File

@ -2,7 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { getErrorMessage } from "@bff/core/utils/error.util.js"; 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 jsforce from "jsforce";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import { createPrivateKey } from "node:crypto"; import { createPrivateKey } from "node:crypto";

View File

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

View File

@ -89,7 +89,7 @@ export class WhmcsCacheService {
status?: string status?: string
): Promise<InvoiceList | null> { ): Promise<InvoiceList | null> {
const key = this.buildInvoicesKey(userId, page, limit, status); 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 data: InvoiceList
): Promise<void> { ): Promise<void> {
const key = this.buildInvoicesKey(userId, page, limit, status); 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> { async getInvoice(userId: string, invoiceId: number): Promise<Invoice | null> {
const key = this.buildInvoiceKey(userId, invoiceId); 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> { async setInvoice(userId: string, invoiceId: number, data: Invoice): Promise<void> {
const key = this.buildInvoiceKey(userId, invoiceId); 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> { async getSubscriptionsList(userId: string): Promise<SubscriptionList | null> {
const key = this.buildSubscriptionsKey(userId); 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> { async setSubscriptionsList(userId: string, data: SubscriptionList): Promise<void> {
const key = this.buildSubscriptionsKey(userId); 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> { async getSubscription(userId: string, subscriptionId: number): Promise<Subscription | null> {
const key = this.buildSubscriptionKey(userId, subscriptionId); 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> { async setSubscription(userId: string, subscriptionId: number, data: Subscription): Promise<void> {
const key = this.buildSubscriptionKey(userId, subscriptionId); 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 limit: number
): Promise<InvoiceList | null> { ): Promise<InvoiceList | null> {
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit); 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 data: InvoiceList
): Promise<void> { ): Promise<void> {
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit); const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit);
await this.set(key, data, "subscriptionInvoices", [ await this.set(key, data, "subscriptionInvoices");
`user:${userId}`,
`subscription:${subscriptionId}`,
]);
} }
/** /**
@ -190,7 +187,7 @@ export class WhmcsCacheService {
*/ */
async getClientData(clientId: number): Promise<WhmcsClient | null> { async getClientData(clientId: number): Promise<WhmcsClient | null> {
const key = this.buildClientKey(clientId); 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) { async setClientData(clientId: number, data: WhmcsClient) {
const key = this.buildClientKey(clientId); 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> { async getClientIdByEmail(email: string): Promise<number | null> {
const key = this.buildClientEmailKey(email); 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> { async getPaymentMethods(userId: string): Promise<PaymentMethodList | null> {
const key = this.buildPaymentMethodsKey(userId); 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> { async setPaymentMethods(userId: string, paymentMethods: PaymentMethodList): Promise<void> {
const key = this.buildPaymentMethodsKey(userId); 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> { async getPaymentGateways(): Promise<PaymentGatewayList | null> {
const key = "whmcs:paymentgateways:global"; 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 * 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 { try {
const data = await this.cacheService.get<T>(key); const data = await this.cacheService.get<T>(key);
if (data) { if (data) {
@ -396,14 +393,9 @@ export class WhmcsCacheService {
/** /**
* Generic set method with configuration * Generic set method with configuration
*/ */
private async set<T>( private async set<T>(key: string, data: T, configKey: string): Promise<void> {
key: string,
data: T,
_configKey: string,
_additionalTags: string[] = []
): Promise<void> {
try { try {
const config = this.cacheConfigs[_configKey]; const config = this.cacheConfigs[configKey];
await this.cacheService.set(key, data, config.ttl); await this.cacheService.set(key, data, config.ttl);
this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`); this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`);
} catch (error) { } 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 { WhmcsConfigService } from "../config/whmcs-config.service.js";
import { WhmcsHttpClientService } from "./whmcs-http-client.service.js"; import { WhmcsHttpClientService } from "./whmcs-http-client.service.js";
import { WhmcsErrorHandlerService } from "./whmcs-error-handler.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 { import type {
WhmcsAddClientParams, WhmcsAddClientParams,
WhmcsValidateLoginParams, WhmcsValidateLoginParams,
@ -104,7 +104,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
} }
// Handle general request errors // 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)) { if (this.isHandledException(error)) {
throw 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.) * 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)) { if (this.isTimeoutError(error)) {
throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); 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 { getErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsCacheService } from "../cache/whmcs-cache.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"; import type { WhmcsClient } from "@customer-portal/domain/customer";
/** /**
@ -43,7 +43,7 @@ export class WhmcsAccountDiscoveryService {
return null; return null;
} }
const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); const client = transformWhmcsClientResponse(response);
// 3. Cache both the data and the mapping // 3. Cache both the data and the mapping
await Promise.all([ await Promise.all([
@ -86,7 +86,7 @@ export class WhmcsAccountDiscoveryService {
throw new NotFoundException(`Client ${clientId} not found`); throw new NotFoundException(`Client ${clientId} not found`);
} }
const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); const client = transformWhmcsClientResponse(response);
await this.cacheService.setClientData(client.id, client); await this.cacheService.setClientData(client.id, client);
return client; return client;
} }

View File

@ -10,7 +10,7 @@ import type {
WhmcsAddClientResponse, WhmcsAddClientResponse,
WhmcsValidateLoginResponse, WhmcsValidateLoginResponse,
} from "@customer-portal/domain/customer/providers"; } 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"; import type { WhmcsClient } from "@customer-portal/domain/customer";
@Injectable() @Injectable()
@ -73,7 +73,7 @@ export class WhmcsClientService {
throw new NotFoundException(`Client ${clientId} not found`); throw new NotFoundException(`Client ${clientId} not found`);
} }
const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); const client = transformWhmcsClientResponse(response);
await this.cacheService.setClientData(client.id, client); await this.cacheService.setClientData(client.id, client);
this.logger.log(`Fetched client details for client ${clientId}`); 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 { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import { invoiceListSchema, invoiceSchema } from "@customer-portal/domain/billing"; import { invoiceListSchema, invoiceSchema } from "@customer-portal/domain/billing";
import type { Invoice, InvoiceList } 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 { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsCurrencyService } from "./whmcs-currency.service.js"; import { WhmcsCurrencyService } from "./whmcs-currency.service.js";
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js"; import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
@ -165,7 +165,7 @@ export class WhmcsInvoiceService {
// Transform invoice using domain mapper // Transform invoice using domain mapper
const defaultCurrency = this.currencyService.getDefaultCurrency(); const defaultCurrency = this.currencyService.getDefaultCurrency();
const invoice = Providers.Whmcs.transformWhmcsInvoice(response, { const invoice = transformWhmcsInvoice(response, {
defaultCurrencyCode: defaultCurrency.code, defaultCurrencyCode: defaultCurrency.code,
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix, defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
}); });
@ -224,7 +224,7 @@ export class WhmcsInvoiceService {
try { try {
// Transform using domain mapper // Transform using domain mapper
const defaultCurrency = this.currencyService.getDefaultCurrency(); const defaultCurrency = this.currencyService.getDefaultCurrency();
const transformed = Providers.Whmcs.transformWhmcsInvoice(whmcsInvoice, { const transformed = transformWhmcsInvoice(whmcsInvoice, {
defaultCurrencyCode: defaultCurrency.code, defaultCurrencyCode: defaultCurrency.code,
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix, defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
}); });

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; 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 { WhmcsCacheService } from "./cache/whmcs-cache.service.js";
import { WhmcsService } from "./whmcs.service.js"; import { WhmcsService } from "./whmcs.service.js";
import { WhmcsInvoiceService } from "./services/whmcs-invoice.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 { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
import type { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments"; import type { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments";
import { addressSchema, type Address, type WhmcsClient } from "@customer-portal/domain/customer"; 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 { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsInvoiceService } from "./services/whmcs-invoice.service.js"; import { WhmcsInvoiceService } from "./services/whmcs-invoice.service.js";
import type { InvoiceFilters } 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> { 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; if (Object.keys(updateData).length === 0) return;
await this.clientService.updateClient(clientId, updateData); 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"; 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 // Validate token format first
if (!token || typeof token !== "string" || token.split(".").length !== 3) { if (!token || typeof token !== "string" || token.split(".").length !== 3) {
this.logger.warn("Invalid token format provided for blacklisting"); this.logger.warn("Invalid token format provided for blacklisting");

View File

@ -27,9 +27,8 @@ import {
type ValidateSignupRequest, type ValidateSignupRequest,
} from "@customer-portal/domain/auth"; } from "@customer-portal/domain/auth";
import { ErrorCode } from "@customer-portal/domain/common"; 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 { 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 { CacheService } from "@bff/infra/cache/cache.service.js";
import { import {
PORTAL_SOURCE_NEW_SIGNUP, PORTAL_SOURCE_NEW_SIGNUP,
@ -38,11 +37,6 @@ import {
} from "@bff/modules/auth/constants/portal.constants.js"; } from "@bff/modules/auth/constants/portal.constants.js";
import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js"; import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js";
type _SanitizedPrismaUser = Omit<
PrismaUser,
"passwordHash" | "failedLoginAttempts" | "lockedUntil"
>;
interface SignupAccountSnapshot { interface SignupAccountSnapshot {
id: string; id: string;
Name?: string | null; Name?: string | null;
@ -358,8 +352,7 @@ export class SignupWorkflowService {
postcode: address.postcode, postcode: address.postcode,
country: address.country, country: address.country,
password2: password, password2: password,
customfields: customfields: serializeWhmcsKeyValueMap(customfieldsMap) || undefined,
CustomerProviders.Whmcs.serializeWhmcsKeyValueMap(customfieldsMap) || undefined,
}); });
this.logger.log("WHMCS client created successfully", { 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 { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.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 type { User } from "@customer-portal/domain/customer";
import { import {
PORTAL_SOURCE_MIGRATED, PORTAL_SOURCE_MIGRATED,
@ -115,11 +115,8 @@ export class WhmcsLinkWorkflowService {
} }
const customerNumber = const customerNumber =
CustomerProviders.Whmcs.getCustomFieldValue(clientDetails.customfields, "198")?.trim() ?? getCustomFieldValue(clientDetails.customfields, "198")?.trim() ??
CustomerProviders.Whmcs.getCustomFieldValue( getCustomFieldValue(clientDetails.customfields, "Customer Number")?.trim();
clientDetails.customfields,
"Customer Number"
)?.trim();
if (!customerNumber) { if (!customerNumber) {
throw new BadRequestException( throw new BadRequestException(

View File

@ -231,7 +231,7 @@ export class AuthController {
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
@ZodResponse({ status: 200, description: "Migrate/link account", type: LinkWhmcsResponseDto }) @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); const result = await this.authFacade.linkWhmcsUser(linkData);
return result; return result;
} }

View File

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

View File

@ -1,5 +1,5 @@
import { Module } from "@nestjs/common"; 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 { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
// New modular invoice services // New modular invoice services
@ -8,15 +8,15 @@ import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js
import { InvoiceHealthService } from "./services/invoice-health.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). * Validation is handled by Zod schemas via Zod DTOs + the global ZodValidationPipe (APP_PIPE).
* No separate validator service needed. * No separate validator service needed.
*/ */
@Module({ @Module({
imports: [WhmcsModule, MappingsModule], imports: [WhmcsModule, MappingsModule],
controllers: [InvoicesController], controllers: [BillingController],
providers: [InvoicesOrchestratorService, InvoiceRetrievalService, InvoiceHealthService], providers: [InvoicesOrchestratorService, InvoiceRetrievalService, InvoiceHealthService],
exports: [InvoicesOrchestratorService], 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 "./billing.module.js";
export * from "./invoices.controller.js"; export * from "./billing.controller.js";
export * from "./services/invoices-orchestrator.service.js"; export * from "./services/invoices-orchestrator.service.js";
export * from "./services/invoice-retrieval.service.js"; export * from "./services/invoice-retrieval.service.js";
export * from "./services/invoice-health.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 { HealthController } from "./health.controller.js";
import { PrismaModule } from "@bff/infra/database/prisma.module.js"; import { PrismaModule } from "@bff/infra/database/prisma.module.js";
import { ConfigModule } from "@nestjs/config"; 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"; import { QueueHealthController } from "@bff/core/health/queue-health.controller.js";
@Module({ @Module({

View File

@ -1,7 +1,7 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { CacheService } from "@bff/infra/cache/cache.service.js"; 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"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
@Injectable() @Injectable()

View File

@ -4,7 +4,7 @@
* Normalized types for mapping portal users to external systems. * 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 { export interface UserIdMapping {
id: string; id: string;

View File

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

View File

@ -16,7 +16,7 @@ import type {
UpdateMappingRequest, UpdateMappingRequest,
MappingSearchFilters, MappingSearchFilters,
MappingStats, MappingStats,
} from "@customer-portal/domain/mappings"; } from "./domain/index.js";
import { import {
createMappingRequestSchema, createMappingRequestSchema,
updateMappingRequestSchema, updateMappingRequestSchema,
@ -25,7 +25,7 @@ import {
checkMappingCompleteness, checkMappingCompleteness,
sanitizeCreateRequest, sanitizeCreateRequest,
sanitizeUpdateRequest, sanitizeUpdateRequest,
} from "@customer-portal/domain/mappings"; } from "./domain/index.js";
import type { Prisma, IdMapping as PrismaIdMapping } from "@prisma/client"; import type { Prisma, IdMapping as PrismaIdMapping } from "@prisma/client";
import { mapPrismaMappingToDomain } from "@bff/infra/mappers/index.js"; 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 * BFF-specific mapping types
@ -18,4 +18,4 @@ export interface CachedMapping {
} }
// Re-export validation result from domain for backward compatibility // 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, type SalesforceOrderFieldMap,
} from "@customer-portal/domain/orders/providers"; } 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> = { const SECTION_PREFIX: Record<keyof SalesforceOrderFieldMap, string> = {
order: "ORDER", 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 { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { UsersModule } from "@bff/modules/users/users.module.js"; import { UsersModule } from "@bff/modules/users/users.module.js";
import { CoreConfigModule } from "@bff/core/config/config.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 { ServicesModule } from "@bff/modules/services/services.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.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, MappingsModule,
UsersModule, UsersModule,
CoreConfigModule, CoreConfigModule,
DatabaseModule,
ServicesModule, ServicesModule,
CacheModule, CacheModule,
VerificationModule, VerificationModule,

View File

@ -49,7 +49,7 @@ export class OrderBuilder {
this.addSimFields(orderFields, body, orderFieldNames); this.addSimFields(orderFields, body, orderFieldNames);
break; break;
case "VPN": case "VPN":
this.addVpnFields(orderFields, body); this.addVpnFields();
break; break;
} }
@ -111,10 +111,7 @@ export class OrderBuilder {
} }
} }
private addVpnFields( private addVpnFields(): void {
_orderFields: Record<string, unknown>,
_body: OrderBusinessValidation
): void {
// No additional fields for VPN orders at this time. // 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 { OrderFulfillmentValidator } from "./order-fulfillment-validator.service.js";
import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service.js"; import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service.js";
import { SimFulfillmentService } from "./sim-fulfillment.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 { getErrorMessage } from "@bff/core/utils/error.util.js";
import { OrderEventsService } from "./order-events.service.js"; import { OrderEventsService } from "./order-events.service.js";
import { OrdersCacheService } from "./orders-cache.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 { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import type { OrderDetails } from "@customer-portal/domain/orders"; import type { OrderDetails } from "@customer-portal/domain/orders";
import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers"; 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 { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
@ -26,7 +26,7 @@ import {
WhmcsOperationException, WhmcsOperationException,
} from "@bff/core/exceptions/domain-exceptions.js"; } from "@bff/core/exceptions/domain-exceptions.js";
type WhmcsOrderItemMappingResult = ReturnType<typeof OrderProviders.Whmcs.mapOrderToWhmcsItems>; type WhmcsOrderItemMappingResult = ReturnType<typeof mapOrderToWhmcsItems>;
export interface OrderFulfillmentStep { export interface OrderFulfillmentStep {
step: string; step: string;
@ -210,7 +210,7 @@ export class OrderFulfillmentOrchestrator {
return Promise.reject(new Error("Order details are required for mapping")); return Promise.reject(new Error("Order details are required for mapping"));
} }
// Use domain mapper directly - single transformation! // Use domain mapper directly - single transformation!
const result = OrderProviders.Whmcs.mapOrderToWhmcsItems(context.orderDetails); const result = mapOrderToWhmcsItems(context.orderDetails);
mappingResult = result; mappingResult = result;
this.logger.log("OrderItems mapped to WHMCS", { this.logger.log("OrderItems mapped to WHMCS", {
@ -240,7 +240,7 @@ export class OrderFulfillmentOrchestrator {
}); });
} }
const orderNotes = OrderProviders.Whmcs.createOrderNotes( const orderNotes = createOrderNotes(
sfOrderId, sfOrderId,
`Provisioned from Salesforce Order ${sfOrderId}` `Provisioned from Salesforce Order ${sfOrderId}`
); );

View File

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

View File

@ -54,7 +54,7 @@ export class AccountServicesController {
@Get("vpn/plans") @Get("vpn/plans")
@RateLimit({ limit: 60, ttl: 60 }) @RateLimit({ limit: 60, ttl: 60 })
@Header("Cache-Control", "private, no-store") @Header("Cache-Control", "private, no-store")
async getVpnCatalogForAccount(@Request() _req: RequestWithUser): Promise<VpnCatalogCollection> { async getVpnCatalogForAccount(): Promise<VpnCatalogCollection> {
const catalog = await this.vpnCatalog.getCatalogData(); const catalog = await this.vpnCatalog.getCatalogData();
return parseVpnCatalog(catalog); 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 { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js"; import { CoreConfigModule } from "@bff/core/config/config.module.js";
import { CacheModule } from "@bff/infra/cache/cache.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 { BaseServicesService } from "./services/base-services.service.js";
import { InternetServicesService } from "./services/internet-services.service.js"; import { InternetServicesService } from "./services/internet-services.service.js";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -107,13 +107,13 @@ export class SimValidationService {
const expectedEid = "89049032000001000000043598005455"; const expectedEid = "89049032000001000000043598005455";
const foundSimNumber = Object.entries(subscription.customFields || {}).find( const foundSimNumber = Object.entries(subscription.customFields || {}).find(
([_key, value]) => ([, value]) =>
value !== undefined && value !== undefined &&
value !== null && value !== null &&
this.formatCustomFieldValue(value).includes(expectedSimNumber) 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; if (value === undefined || value === null) return false;
return this.formatCustomFieldValue(value).includes(expectedEid); 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 { Logger } from "nestjs-pino";
import type * as Providers from "@customer-portal/domain/subscriptions/providers"; import type * as Providers from "@customer-portal/domain/subscriptions/providers";
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw; type WhmcsProduct = Providers.WhmcsProductRaw;
export interface GetSubscriptionsOptions { export interface GetSubscriptionsOptions {
status?: SubscriptionStatus; status?: SubscriptionStatus;

View File

@ -15,7 +15,10 @@ import {
type Address, type Address,
type User, type User,
} from "@customer-portal/domain/customer"; } from "@customer-portal/domain/customer";
import * as CustomerProviders from "@customer-portal/domain/customer/providers"; import {
getCustomFieldValue,
mapPrismaUserToUserAuth,
} from "@customer-portal/domain/customer/providers";
import { import {
updateCustomerProfileRequestSchema, updateCustomerProfileRequestSchema,
type UpdateCustomerProfileRequest, type UpdateCustomerProfileRequest,
@ -149,7 +152,11 @@ export class UserProfileService {
} }
// Allow phone/company/language updates through to WHMCS // 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) { if (Object.keys(whmcsUpdate).length > 0) {
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdate); await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdate);
} }
@ -432,7 +439,7 @@ export class UserProfileService {
try { try {
const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId); const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user); const userAuth = mapPrismaUserToUserAuth(user);
const base = combineToUser(userAuth, whmcsClient); const base = combineToUser(userAuth, whmcsClient);
// Portal-visible identifiers (read-only). These are stored in WHMCS custom fields. // 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 genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
const rawSfNumber = customerNumberFieldId const rawSfNumber = customerNumberFieldId
? CustomerProviders.Whmcs.getCustomFieldValue( ? getCustomFieldValue(whmcsClient.customfields, customerNumberFieldId)
whmcsClient.customfields,
customerNumberFieldId
)
: undefined; : undefined;
const rawDob = dobFieldId const rawDob = dobFieldId
? CustomerProviders.Whmcs.getCustomFieldValue(whmcsClient.customfields, dobFieldId) ? getCustomFieldValue(whmcsClient.customfields, dobFieldId)
: undefined; : undefined;
const rawGender = genderFieldId const rawGender = genderFieldId
? CustomerProviders.Whmcs.getCustomFieldValue(whmcsClient.customfields, genderFieldId) ? getCustomFieldValue(whmcsClient.customfields, genderFieldId)
: undefined; : undefined;
const sfNumber = rawSfNumber?.trim() ? rawSfNumber.trim() : null; 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, addressFormToRequest,
type AddressFormData, type AddressFormData,
} from "@customer-portal/domain/customer"; } from "@customer-portal/domain/customer";
import { useZodForm } from "@/hooks/useZodForm"; import { useZodForm } from "@/lib/hooks/useZodForm";
export function useAddressEdit(initial: AddressFormData) { export function useAddressEdit(initial: AddressFormData) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@ -9,7 +9,7 @@ import {
type ProfileEditFormData, type ProfileEditFormData,
} from "@customer-portal/domain/customer"; } from "@customer-portal/domain/customer";
import { type UpdateCustomerProfileRequest } from "@customer-portal/domain/auth"; import { type UpdateCustomerProfileRequest } from "@customer-portal/domain/auth";
import { useZodForm } from "@/hooks/useZodForm"; import { useZodForm } from "@/lib/hooks/useZodForm";
export function useProfileEdit(initial: ProfileEditFormData) { export function useProfileEdit(initial: ProfileEditFormData) {
const handleSave = useCallback(async (formData: 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 { FormField } from "@/components/molecules/FormField/FormField";
import { useWhmcsLink } from "@/features/auth/hooks"; import { useWhmcsLink } from "@/features/auth/hooks";
import { linkWhmcsRequestSchema, type LinkWhmcsResponse } from "@customer-portal/domain/auth"; import { linkWhmcsRequestSchema, type LinkWhmcsResponse } from "@customer-portal/domain/auth";
import { useZodForm } from "@/hooks/useZodForm"; import { useZodForm } from "@/lib/hooks/useZodForm";
interface LinkWhmcsFormProps { interface LinkWhmcsFormProps {
onTransferred?: (result: LinkWhmcsResponse) => void; 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 { FormField } from "@/components/molecules/FormField/FormField";
import { useLogin } from "../../hooks/use-auth"; import { useLogin } from "../../hooks/use-auth";
import { loginRequestSchema } from "@customer-portal/domain/auth"; import { loginRequestSchema } from "@customer-portal/domain/auth";
import { useZodForm } from "@/hooks/useZodForm"; import { useZodForm } from "@/lib/hooks/useZodForm";
import { z } from "zod"; import { z } from "zod";
import { getSafeRedirect } from "@/features/auth/utils/route-protection"; 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 { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { usePasswordReset } from "../../hooks/use-auth"; 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 { passwordResetRequestSchema, passwordResetSchema } from "@customer-portal/domain/auth";
import { z } from "zod"; import { z } from "zod";

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service"; import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import { useAuthStore } from "@/features/auth/services/auth.store"; 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; 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 { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import type { SimReissueFullRequest } from "@customer-portal/domain/sim"; import type { SimReissueFullRequest } from "@customer-portal/domain/sim";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; 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"; import { Button } from "@/components/atoms";
type SimType = "physical" | "esim"; type SimType = "physical" | "esim";

View File

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

View File

@ -5,7 +5,7 @@ import Link from "next/link";
import { Button, Input } from "@/components/atoms"; import { Button, Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; 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 { Mail, CheckCircle, MapPin } from "lucide-react";
import { import {
publicContactRequestSchema, publicContactRequestSchema,

View File

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

View File

@ -99,7 +99,7 @@ export function mapPrismaUserToDomain(user: PrismaUser): AuthenticatedUser {
```typescript ```typescript
import type { IdMapping as PrismaIdMapping } from "@prisma/client"; 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 * Maps Prisma IdMapping entity to Domain UserIdMapping type

View File

@ -14,6 +14,20 @@
- **BFF-only (integration/infrastructure)**: - **BFF-only (integration/infrastructure)**:
- `@customer-portal/domain/<module>/providers` - `@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 ## Quick Reference
| Context | Import Pattern | Example | | Context | Import Pattern | Example |

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