Remove OpenAPI generation and related scripts from the BFF. Transition to a Zod-based validation approach for API requests and responses, enhancing type safety and consistency. Update package.json to reflect the removal of OpenAPI dependencies and streamline type generation processes. Revise documentation to clarify the new structure and usage of shared Zod schemas from the domain package. Refactor various controllers and services to eliminate OpenAPI references and integrate Zod validation, improving overall code maintainability.

This commit is contained in:
barsa 2025-10-02 17:19:39 +09:00
parent a4e6ba73de
commit d04e343161
37 changed files with 522 additions and 2001 deletions

View File

@ -35,7 +35,7 @@ A modern customer portal where users can self-register, log in, browse & buy sub
- **jsforce** for Salesforce integration
- **WHMCS** API client
- **BullMQ** for async jobs with ioredis
- **OpenAPI/Swagger** for documentation
- **Zod-first validation** shared via the domain package
### Temporarily Disabled Modules
@ -65,10 +65,10 @@ A modern customer portal where users can self-register, log in, browse & buy sub
new-portal-website/
├── apps/
│ ├── portal/ # Next.js 15 frontend (React 19, Tailwind, shadcn/ui)
│ └── bff/ # NestJS 11 backend (Prisma, BullMQ, OpenAPI)
│ └── bff/ # NestJS 11 backend (Prisma, BullMQ, Zod validation)
├── packages/
│ ├── shared/ # Shared types and utilities
│ └── api-client/ # Generated OpenAPI client and types
│ └── api-client/ # Lightweight fetch helpers + shared Zod types
├── scripts/
│ ├── dev/ # Development management scripts
│ └── prod/ # Production deployment scripts
@ -128,7 +128,6 @@ new-portal-website/
4. **Access Your Applications**
- **Frontend**: http://localhost:3000
- **Backend API**: http://localhost:4000/api
- **API Documentation**: http://localhost:4000/api/docs
### Development Commands
@ -167,23 +166,20 @@ Upload the tar files in Plesk → Docker → Images → Upload, then deploy usin
### API Client
The portal uses an integrated OpenAPI-based client with automatic type generation:
1. **Generate OpenAPI spec** from BFF (runs automatically on build):
```bash
pnpm openapi:gen
```
2. **Types are auto-generated** in `apps/portal/src/lib/api/__generated__/types.ts`
3. **Use the client** in Portal:
The portal uses a lightweight fetch client that shares request/response contracts from
`@customer-portal/domain` and validates them with Zod:
```ts
import { apiClient } from "@/lib/api";
// Client includes CSRF protection, auth headers, and error handling
import { apiClient, getDataOrThrow } from "@/lib/api";
import type { DashboardSummary } from "@customer-portal/domain";
const response = await apiClient.GET<DashboardSummary>("/api/me/summary");
const summary = getDataOrThrow(response);
```
Because the schemas and types live in the shared domain package there is no code
generation step—`pnpm types:gen` is now a no-op placeholder.
### Environment Configuration
- Local development: use the root `.env` (see `.env.example`).

View File

@ -1,605 +0,0 @@
{
"openapi": "3.0.0",
"paths": {
"/minimal": {
"get": {
"operationId": "MinimalController_getMinimal",
"parameters": [],
"responses": {
"200": {
"description": "Success"
}
},
"summary": "Minimal endpoint for OpenAPI generation",
"tags": [
"System"
]
}
},
"/invoices": {
"get": {
"description": "Retrieves invoices for the authenticated user with pagination and optional status filtering",
"operationId": "InvoicesController_getInvoices",
"parameters": [
{
"name": "status",
"required": false,
"in": "query",
"description": "Filter by invoice status",
"schema": {
"type": "string"
}
},
{
"name": "limit",
"required": false,
"in": "query",
"description": "Items per page (default: 10)",
"schema": {
"type": "number"
}
},
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number (default: 1)",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "List of invoices with pagination",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvoiceListDto"
}
}
}
}
},
"security": [
{
"bearer": []
}
],
"summary": "Get paginated list of user invoices",
"tags": [
"invoices"
]
}
},
"/invoices/payment-methods": {
"get": {
"description": "Retrieves all saved payment methods for the authenticated user",
"operationId": "InvoicesController_getPaymentMethods",
"parameters": [],
"responses": {
"200": {
"description": "List of payment methods"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Get user payment methods",
"tags": [
"invoices"
]
}
},
"/invoices/payment-gateways": {
"get": {
"description": "Retrieves all active payment gateways available for payments",
"operationId": "InvoicesController_getPaymentGateways",
"parameters": [],
"responses": {
"200": {
"description": "List of payment gateways"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Get available payment gateways",
"tags": [
"invoices"
]
}
},
"/invoices/payment-methods/refresh": {
"post": {
"description": "Invalidates and refreshes payment methods cache for the current user",
"operationId": "InvoicesController_refreshPaymentMethods",
"parameters": [],
"responses": {
"200": {
"description": "Payment methods cache refreshed"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Refresh payment methods cache",
"tags": [
"invoices"
]
}
},
"/invoices/{id}": {
"get": {
"description": "Retrieves detailed information for a specific invoice",
"operationId": "InvoicesController_getInvoiceById",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"description": "Invoice ID",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "Invoice details",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvoiceDto"
}
}
}
},
"404": {
"description": "Invoice not found"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Get invoice details by ID",
"tags": [
"invoices"
]
}
},
"/invoices/{id}/subscriptions": {
"get": {
"description": "Retrieves all subscriptions that are referenced in the invoice items",
"operationId": "InvoicesController_getInvoiceSubscriptions",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"description": "Invoice ID",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "List of related subscriptions"
},
"404": {
"description": "Invoice not found"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Get subscriptions related to an invoice",
"tags": [
"invoices"
]
}
},
"/invoices/{id}/sso-link": {
"post": {
"description": "Generates a single sign-on link to view/pay the invoice or download PDF in WHMCS",
"operationId": "InvoicesController_createSsoLink",
"parameters": [
{
"name": "target",
"required": false,
"in": "query",
"description": "Link target: view invoice, download PDF, or go to payment page (default: view)",
"schema": {
"enum": [
"view",
"download",
"pay"
],
"type": "string"
}
},
{
"name": "id",
"required": true,
"in": "path",
"description": "Invoice ID",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "SSO link created successfully"
},
"404": {
"description": "Invoice not found"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Create SSO link for invoice",
"tags": [
"invoices"
]
}
},
"/invoices/{id}/payment-link": {
"post": {
"description": "Generates a payment link for the invoice with a specific payment method or gateway",
"operationId": "InvoicesController_createPaymentLink",
"parameters": [
{
"name": "gatewayName",
"required": false,
"in": "query",
"description": "Payment gateway name",
"schema": {
"type": "string"
}
},
{
"name": "paymentMethodId",
"required": false,
"in": "query",
"description": "Payment method ID",
"schema": {
"type": "number"
}
},
{
"name": "id",
"required": true,
"in": "path",
"description": "Invoice ID",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "Payment link created successfully"
},
"404": {
"description": "Invoice not found"
}
},
"security": [
{
"bearer": []
}
],
"summary": "Create payment link for invoice with payment method",
"tags": [
"invoices"
]
}
}
},
"info": {
"title": "Customer Portal API",
"description": "Backend for Frontend API for customer portal",
"version": "1.0",
"contact": {}
},
"tags": [],
"servers": [],
"components": {
"securitySchemes": {
"bearer": {
"scheme": "bearer",
"bearerFormat": "JWT",
"type": "http"
}
},
"schemas": {
"InvoiceListDto": {
"type": "object",
"properties": {
"invoices": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"number": {
"type": "string",
"minLength": 1
},
"status": {
"type": "string",
"enum": [
"Draft",
"Pending",
"Paid",
"Unpaid",
"Overdue",
"Cancelled",
"Refunded",
"Collections"
]
},
"currency": {
"type": "string",
"minLength": 1
},
"currencySymbol": {
"type": "string",
"minLength": 1
},
"total": {
"type": "number"
},
"subtotal": {
"type": "number"
},
"tax": {
"type": "number"
},
"issuedAt": {
"type": "string"
},
"dueDate": {
"type": "string"
},
"paidDate": {
"type": "string"
},
"pdfUrl": {
"type": "string"
},
"paymentUrl": {
"type": "string"
},
"description": {
"type": "string"
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"description": {
"type": "string",
"minLength": 1
},
"amount": {
"type": "number"
},
"quantity": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"type": {
"type": "string",
"minLength": 1
},
"serviceId": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"required": [
"id",
"description",
"amount",
"type"
]
}
},
"daysOverdue": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": [
"id",
"number",
"status",
"currency",
"total",
"subtotal",
"tax"
]
}
},
"pagination": {
"type": "object",
"properties": {
"page": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"totalPages": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"totalItems": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"nextCursor": {
"type": "string"
}
},
"required": [
"page",
"totalPages",
"totalItems"
]
}
},
"required": [
"invoices",
"pagination"
]
},
"InvoiceDto": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"number": {
"type": "string",
"minLength": 1
},
"status": {
"type": "string",
"enum": [
"Draft",
"Pending",
"Paid",
"Unpaid",
"Overdue",
"Cancelled",
"Refunded",
"Collections"
]
},
"currency": {
"type": "string",
"minLength": 1
},
"currencySymbol": {
"type": "string",
"minLength": 1
},
"total": {
"type": "number"
},
"subtotal": {
"type": "number"
},
"tax": {
"type": "number"
},
"issuedAt": {
"type": "string"
},
"dueDate": {
"type": "string"
},
"paidDate": {
"type": "string"
},
"pdfUrl": {
"type": "string"
},
"paymentUrl": {
"type": "string"
},
"description": {
"type": "string"
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"description": {
"type": "string",
"minLength": 1
},
"amount": {
"type": "number"
},
"quantity": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"type": {
"type": "string",
"minLength": 1
},
"serviceId": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"required": [
"id",
"description",
"amount",
"type"
]
}
},
"daysOverdue": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": [
"id",
"number",
"status",
"currency",
"total",
"subtotal",
"tax"
]
}
}
}
}

View File

@ -27,8 +27,7 @@
"db:generate": "prisma generate",
"db:studio": "prisma studio",
"db:reset": "prisma migrate reset",
"db:seed": "NODE_OPTIONS=\"--max-old-space-size=4096\" tsx prisma/seed.ts",
"openapi:gen": "NODE_OPTIONS=\"--max-old-space-size=4096\" tsx -r tsconfig-paths/register ./scripts/generate-openapi.ts"
"db:seed": "NODE_OPTIONS=\"--max-old-space-size=4096\" tsx prisma/seed.ts"
},
"dependencies": {
"@customer-portal/domain": "workspace:*",
@ -40,7 +39,6 @@
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.6",
"@nestjs/swagger": "^11.2.0",
"@nestjs/throttler": "^6.4.0",
"@prisma/client": "^6.14.0",
"@sendgrid/mail": "^8.1.6",

View File

@ -1,36 +0,0 @@
import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { writeFileSync, mkdirSync } from "fs";
import { join } from "path";
import { OpenApiModule } from "./openapi.module";
async function generate() {
try {
console.log("Creating NestJS application...");
const app = await NestFactory.create(OpenApiModule, { logger: false });
console.log("Building OpenAPI config...");
const config = new DocumentBuilder()
.setTitle("Customer Portal API")
.setDescription("Backend for Frontend API for customer portal")
.setVersion("1.0")
.addBearerAuth()
.build();
console.log("Generating OpenAPI document...");
const document = SwaggerModule.createDocument(app, config);
console.log("Writing OpenAPI file...");
const outDir = join(process.cwd(), "openapi");
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "openapi.json"), JSON.stringify(document, null, 2));
console.log("OpenAPI generation completed successfully!");
await app.close();
} catch (error) {
console.error("Error generating OpenAPI:", error);
process.exit(1);
}
}
void generate();

View File

@ -1,13 +0,0 @@
import { Controller, Get } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
@ApiTags("System")
@Controller("minimal")
export class MinimalController {
@Get()
@ApiOperation({ summary: "Minimal endpoint for OpenAPI generation" })
@ApiResponse({ status: 200, description: "Success" })
getMinimal(): { message: string } {
return { message: "OpenAPI generation successful" };
}
}

View File

@ -1,55 +0,0 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { MinimalController } from "./minimal.controller";
// Import controllers for OpenAPI generation
import { InvoicesController } from "../src/modules/invoices/invoices.controller";
/**
* OpenAPI generation module
* Includes all controllers but with minimal dependencies for schema generation
*/
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
ignoreEnvFile: true, // Don't require .env file
load: [
() => ({
NODE_ENV: "development",
JWT_SECRET: "temp-secret-for-openapi-generation-only-32-chars",
DATABASE_URL: "postgresql://temp:temp@localhost:5432/temp",
REDIS_URL: "redis://localhost:6379",
BFF_PORT: 4000,
APP_NAME: "customer-portal-bff",
APP_BASE_URL: "http://localhost:3000",
}),
],
}),
],
controllers: [
MinimalController,
InvoicesController,
],
providers: [
// Mock providers for controllers that need them
{
provide: "InvoicesOrchestratorService",
useValue: {},
},
{
provide: "WhmcsService",
useValue: {},
},
{
provide: "MappingsService",
useValue: {},
},
// Add other required services as mocks
{
provide: "Logger",
useValue: { log: () => {}, error: () => {}, warn: () => {} },
},
],
})
export class OpenApiModule {}

View File

@ -1,7 +1,6 @@
import { type INestApplication } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { Logger } from "nestjs-pino";
import helmet from "helmet";
import cookieParser from "cookie-parser";
@ -128,22 +127,6 @@ export async function bootstrap(): Promise<INestApplication> {
// Rely on Nest's built-in shutdown hooks. External orchestrator will send signals.
app.enableShutdownHooks();
// Swagger documentation (only in non-production) - SETUP BEFORE GLOBAL PREFIX
if (configService.get("NODE_ENV") !== "production") {
const config = new DocumentBuilder()
.setTitle("Customer Portal API")
.setDescription("Backend for Frontend API for customer portal")
.setVersion("1.0")
.addBearerAuth()
.addCookieAuth("auth-cookie")
.addServer("http://localhost:4000", "Development server")
.addServer("https://api.yourdomain.com", "Production server")
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("docs", app, document);
}
// API routing prefix is applied via RouterModule in AppModule for clarity and modern routing.
const port = Number(configService.get("BFF_PORT", 4000));
@ -161,9 +144,5 @@ export async function bootstrap(): Promise<INestApplication> {
logger.log("🔴 Redis connection configured");
}
if (configService.get("NODE_ENV") !== "production") {
logger.log(`📚 API Documentation: http://localhost:${port}/docs`);
}
return app;
}

View File

@ -12,21 +12,21 @@ export interface DevAuthConfig {
}
export const createDevAuthConfig = (): DevAuthConfig => {
const isDevelopment = process.env.NODE_ENV !== 'production';
const isDevelopment = process.env.NODE_ENV !== "production";
return {
// Disable CSRF protection in development for easier testing
disableCsrf: isDevelopment && process.env.DISABLE_CSRF === 'true',
disableCsrf: isDevelopment && process.env.DISABLE_CSRF === "true",
// Disable rate limiting in development
disableRateLimit: isDevelopment && process.env.DISABLE_RATE_LIMIT === 'true',
disableRateLimit: isDevelopment && process.env.DISABLE_RATE_LIMIT === "true",
// Disable account locking in development
disableAccountLocking: isDevelopment && process.env.DISABLE_ACCOUNT_LOCKING === 'true',
disableAccountLocking: isDevelopment && process.env.DISABLE_ACCOUNT_LOCKING === "true",
// Enable debug logs in development
enableDebugLogs: isDevelopment,
// Show detailed error messages in development
simplifiedErrorMessages: isDevelopment,
};

View File

@ -1,9 +1,7 @@
import { Controller, Get } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
import { WhmcsRequestQueueService } from "@bff/core/queue/services/whmcs-request-queue.service";
import { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service";
@ApiTags("Health")
@Controller("health/queues")
export class QueueHealthController {
constructor(
@ -12,14 +10,6 @@ export class QueueHealthController {
) {}
@Get()
@ApiOperation({
summary: "Get queue health status",
description: "Returns health status and metrics for WHMCS and Salesforce request queues",
})
@ApiResponse({
status: 200,
description: "Queue health status retrieved successfully",
})
getQueueHealth() {
return {
timestamp: new Date().toISOString(),
@ -36,14 +26,6 @@ export class QueueHealthController {
}
@Get("whmcs")
@ApiOperation({
summary: "Get WHMCS queue metrics",
description: "Returns detailed metrics for the WHMCS request queue",
})
@ApiResponse({
status: 200,
description: "WHMCS queue metrics retrieved successfully",
})
getWhmcsQueueMetrics() {
return {
timestamp: new Date().toISOString(),
@ -53,15 +35,6 @@ export class QueueHealthController {
}
@Get("salesforce")
@ApiOperation({
summary: "Get Salesforce queue metrics",
description:
"Returns detailed metrics for the Salesforce request queue including daily API usage",
})
@ApiResponse({
status: 200,
description: "Salesforce queue metrics retrieved successfully",
})
getSalesforceQueueMetrics() {
return {
timestamp: new Date().toISOString(),

View File

@ -2,7 +2,6 @@ import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/commo
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import PQueue from "p-queue";
export interface SalesforceQueueMetrics {
totalRequests: number;
completedRequests: number;
@ -67,7 +66,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
this.dailyUsageResetTime = this.getNextDayReset();
}
private async initializeQueues() {
private ensureQueuesInitialized(): { standardQueue: PQueue; longRunningQueue: PQueue } {
if (!this.standardQueue || !this.longRunningQueue) {
const concurrency = this.configService.get<number>("SF_QUEUE_CONCURRENCY", 15);
const longRunningConcurrency = this.configService.get<number>(
@ -102,10 +101,22 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
// Set up queue event listeners
this.setupQueueListeners();
}
const standardQueue = this.standardQueue;
const longRunningQueue = this.longRunningQueue;
if (!standardQueue || !longRunningQueue) {
throw new Error("Failed to initialize Salesforce queues");
}
return {
standardQueue,
longRunningQueue,
};
}
async onModuleInit() {
await this.initializeQueues();
onModuleInit() {
this.ensureQueuesInitialized();
const concurrency = this.configService.get<number>("SF_QUEUE_CONCURRENCY", 15);
const longRunningConcurrency = this.configService.get<number>(
"SF_QUEUE_LONG_RUNNING_CONCURRENCY",
@ -128,16 +139,24 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
}
async onModuleDestroy() {
const standardQueue = this.standardQueue;
const longRunningQueue = this.longRunningQueue;
if (!standardQueue || !longRunningQueue) {
this.logger.debug("Salesforce Request Queue destroyed before initialization");
return;
}
this.logger.log("Shutting down Salesforce Request Queue", {
standardPending: this.standardQueue.pending,
standardQueueSize: this.standardQueue.size,
longRunningPending: this.longRunningQueue.pending,
longRunningQueueSize: this.longRunningQueue.size,
standardPending: standardQueue.pending,
standardQueueSize: standardQueue.size,
longRunningPending: longRunningQueue.pending,
longRunningQueueSize: longRunningQueue.size,
});
// Wait for pending requests to complete (with timeout)
try {
await Promise.all([this.standardQueue.onIdle(), this.longRunningQueue.onIdle()]);
await Promise.all([standardQueue.onIdle(), longRunningQueue.onIdle()]);
} catch (error) {
this.logger.warn("Some Salesforce requests may not have completed during shutdown", {
error: error instanceof Error ? error.message : String(error),
@ -152,18 +171,14 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
requestFn: () => Promise<T>,
options: SalesforceRequestOptions = {}
): Promise<T> {
await this.initializeQueues();
const { standardQueue, longRunningQueue } = this.ensureQueuesInitialized();
// Check daily API usage
this.checkDailyUsage();
const startTime = Date.now();
const requestId = this.generateRequestId();
const isLongRunning = options.isLongRunning || false;
const queue = isLongRunning ? this.longRunningQueue : this.standardQueue;
if (!queue) {
throw new Error("Queue not initialized");
}
const queue = isLongRunning ? longRunningQueue : standardQueue;
this.metrics.totalRequests++;
this.metrics.dailyApiUsage++;
@ -326,17 +341,19 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
* Clear the queues (emergency use only)
*/
async clearQueues(): Promise<void> {
const { standardQueue, longRunningQueue } = this.ensureQueuesInitialized();
this.logger.warn("Clearing Salesforce request queues", {
standardQueueSize: this.standardQueue.size,
standardPending: this.standardQueue.pending,
longRunningQueueSize: this.longRunningQueue.size,
longRunningPending: this.longRunningQueue.pending,
standardQueueSize: standardQueue.size,
standardPending: standardQueue.pending,
longRunningQueueSize: longRunningQueue.size,
longRunningPending: longRunningQueue.pending,
});
this.standardQueue.clear();
this.longRunningQueue.clear();
standardQueue.clear();
longRunningQueue.clear();
await Promise.all([this.standardQueue.onIdle(), this.longRunningQueue.onIdle()]);
await Promise.all([standardQueue.onIdle(), longRunningQueue.onIdle()]);
}
private async executeWithRetry<T>(
@ -449,6 +466,10 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
}
private updateQueueMetrics(): void {
if (!this.standardQueue || !this.longRunningQueue) {
return;
}
this.metrics.queueSize = this.standardQueue.size + this.longRunningQueue.size;
this.metrics.pendingRequests = this.standardQueue.pending + this.longRunningQueue.pending;

View File

@ -2,7 +2,6 @@ import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/commo
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import PQueue from "p-queue";
export interface WhmcsQueueMetrics {
totalRequests: number;
completedRequests: number;
@ -58,7 +57,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
private readonly configService: ConfigService
) {}
private async initializeQueue() {
private ensureQueueInitialized(): PQueue {
if (!this.queue) {
const concurrency = this.configService.get<number>("WHMCS_QUEUE_CONCURRENCY", 15);
const intervalCap = this.configService.get<number>("WHMCS_QUEUE_INTERVAL_CAP", 300);
@ -77,10 +76,16 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
// Set up queue event listeners
this.setupQueueListeners();
}
if (!this.queue) {
throw new Error("Failed to initialize WHMCS queue");
}
return this.queue;
}
async onModuleInit() {
await this.initializeQueue();
onModuleInit() {
this.ensureQueueInitialized();
const concurrency = this.configService.get<number>("WHMCS_QUEUE_CONCURRENCY", 15);
const intervalCap = this.configService.get<number>("WHMCS_QUEUE_INTERVAL_CAP", 300);
const timeout = this.configService.get<number>("WHMCS_QUEUE_TIMEOUT_MS", 30000);
@ -112,26 +117,22 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
* Execute a WHMCS API request through the queue
*/
async execute<T>(requestFn: () => Promise<T>, options: WhmcsRequestOptions = {}): Promise<T> {
await this.initializeQueue();
const queue = this.ensureQueueInitialized();
const startTime = Date.now();
const requestId = this.generateRequestId();
if (!this.queue) {
throw new Error("Queue not initialized");
}
this.metrics.totalRequests++;
this.updateQueueMetrics();
this.logger.debug("Queueing WHMCS request", {
requestId,
queueSize: this.queue.size,
pending: this.queue.pending,
queueSize: queue.size,
pending: queue.pending,
priority: options.priority || 0,
});
try {
const result = await this.queue.add(
const result = await queue.add(
async () => {
const waitTime = Date.now() - startTime;
this.recordWaitTime(waitTime);
@ -237,17 +238,19 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
* Clear the queue (emergency use only)
*/
async clearQueue(): Promise<void> {
if (!this.queue) {
const queue = this.queue;
if (!queue) {
return;
}
this.logger.warn("Clearing WHMCS request queue", {
queueSize: this.queue.size,
pendingRequests: this.queue.pending,
queueSize: queue.size,
pendingRequests: queue.pending,
});
this.queue.clear();
await this.queue.onIdle();
queue.clear();
await queue.onIdle();
}
private async executeWithRetry<T>(
@ -287,6 +290,10 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
}
private setupQueueListeners(): void {
if (!this.queue) {
return;
}
this.queue.on("add", () => {
this.updateQueueMetrics();
});
@ -308,6 +315,10 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
}
private updateQueueMetrics(): void {
if (!this.queue) {
return;
}
this.metrics.queueSize = this.queue.size;
this.metrics.pendingRequests = this.queue.pending;

View File

@ -1,15 +1,14 @@
import { Controller, Get, Post, Req, Res, Inject } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
import type { Request, Response } from "express";
import { Logger } from "nestjs-pino";
import { CsrfService } from "../services/csrf.service";
interface AuthenticatedRequest extends Request {
type AuthenticatedRequest = Request & {
user?: { id: string; sessionId?: string };
sessionID?: string;
}
cookies: Record<string, string | undefined>;
};
@ApiTags("Security")
@Controller("security/csrf")
export class CsrfController {
constructor(
@ -18,22 +17,6 @@ export class CsrfController {
) {}
@Get("token")
@ApiOperation({
summary: "Get CSRF token",
description: "Generates and returns a new CSRF token for the current session",
})
@ApiResponse({
status: 200,
description: "CSRF token generated successfully",
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
token: { type: "string", example: "abc123..." },
expiresAt: { type: "string", format: "date-time" },
},
},
})
getCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined;
const userId = req.user?.id;
@ -65,23 +48,6 @@ export class CsrfController {
}
@Post("refresh")
@ApiBearerAuth()
@ApiOperation({
summary: "Refresh CSRF token",
description: "Invalidates current token and generates a new one for authenticated users",
})
@ApiResponse({
status: 200,
description: "CSRF token refreshed successfully",
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
token: { type: "string", example: "xyz789..." },
expiresAt: { type: "string", format: "date-time" },
},
},
})
refreshCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined;
const userId = req.user?.id || "anonymous"; // Default for unauthenticated users
@ -116,31 +82,6 @@ export class CsrfController {
}
@Get("stats")
@ApiBearerAuth()
@ApiOperation({
summary: "Get CSRF token statistics",
description: "Returns statistics about CSRF tokens (admin/monitoring endpoint)",
})
@ApiResponse({
status: 200,
description: "CSRF token statistics",
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
stats: {
type: "object",
properties: {
totalTokens: { type: "number", example: 150 },
activeTokens: { type: "number", example: 120 },
expiredTokens: { type: "number", example: 30 },
cacheSize: { type: "number", example: 150 },
maxCacheSize: { type: "number", example: 10000 },
},
},
},
},
})
getCsrfStats(@Req() req: AuthenticatedRequest) {
const userId = req.user?.id || "anonymous";
@ -160,6 +101,8 @@ export class CsrfController {
}
private extractSessionId(req: AuthenticatedRequest): string | null {
return req.cookies?.["session-id"] || req.cookies?.["connect.sid"] || req.sessionID || null;
const cookies = req.cookies as Record<string, string | undefined> | undefined;
const sessionCookie = cookies?.["session-id"] ?? cookies?.["connect.sid"];
return sessionCookie ?? req.sessionID ?? null;
}
}

View File

@ -5,11 +5,31 @@ import type { Request, Response, NextFunction } from "express";
import { CsrfService } from "../services/csrf.service";
import { devAuthConfig } from "../../config/auth-dev.config";
interface CsrfRequest extends Request {
interface CsrfRequestBody {
_csrf?: string | string[];
csrfToken?: string | string[];
[key: string]: unknown;
}
type QueryValue = string | string[] | undefined;
type CsrfRequestQuery = Record<string, QueryValue>;
type CookieJar = Record<string, string | undefined>;
type BaseExpressRequest = Request<
Record<string, string>,
unknown,
CsrfRequestBody,
CsrfRequestQuery
>;
type CsrfRequest = Omit<BaseExpressRequest, "cookies"> & {
csrfToken?: string;
user?: { id: string; sessionId?: string };
sessionID?: string;
}
cookies: CookieJar;
};
/**
* CSRF Protection Middleware
@ -31,7 +51,7 @@ export class CsrfMiddleware implements NestMiddleware {
// Paths that don't require CSRF protection
this.exemptPaths = new Set([
"/api/auth/login",
"/api/auth/signup",
"/api/auth/signup",
"/api/auth/refresh",
"/api/auth/check-password-needed",
"/api/auth/request-password-reset",
@ -159,7 +179,11 @@ export class CsrfMiddleware implements NestMiddleware {
const sessionId = req.user?.sessionId || this.extractSessionId(req);
const userId = req.user?.id;
const tokenData = this.csrfService.generateToken(existingSecret, sessionId || undefined, userId);
const tokenData = this.csrfService.generateToken(
existingSecret,
sessionId ?? undefined,
userId
);
this.setCsrfSecretCookie(res, tokenData.secret, tokenData.expiresAt);
@ -193,15 +217,19 @@ export class CsrfMiddleware implements NestMiddleware {
}
// 4. Request body (for form submissions)
if (req.body && typeof req.body === "object") {
token = req.body._csrf || req.body.csrfToken;
if (token) return token;
const bodyToken =
this.normalizeTokenValue(req.body._csrf) ?? this.normalizeTokenValue(req.body.csrfToken);
if (bodyToken) {
return bodyToken;
}
// 5. Query parameter (least secure, only for GET requests)
if (req.method === "GET") {
token = (req.query._csrf as string) || (req.query.csrfToken as string);
if (token) return token;
const queryToken =
this.normalizeTokenValue(req.query._csrf) ?? this.normalizeTokenValue(req.query.csrfToken);
if (queryToken) {
return queryToken;
}
}
return null;
@ -209,12 +237,24 @@ export class CsrfMiddleware implements NestMiddleware {
private extractSecretFromCookie(req: CsrfRequest): string | null {
const cookieName = this.csrfService.getCookieName();
return req.cookies?.[cookieName] || null;
const cookies = req.cookies;
if (!cookies) {
return null;
}
const secret = cookies[cookieName];
return typeof secret === "string" && secret.length > 0 ? secret : null;
}
private extractSessionId(req: CsrfRequest): string | null {
// Try to extract session ID from various sources
return req.cookies?.["session-id"] || req.cookies?.["connect.sid"] || req.sessionID || null;
const cookies = req.cookies;
const sessionId = this.pickFirstString(
cookies?.["session-id"],
cookies?.["connect.sid"],
req.sessionID
);
return sessionId ?? null;
}
private setCsrfSecretCookie(res: Response, secret: string, expiresAt?: Date): void {
@ -228,4 +268,31 @@ export class CsrfMiddleware implements NestMiddleware {
res.cookie(this.csrfService.getCookieName(), secret, cookieOptions);
}
private normalizeTokenValue(value: string | string[] | undefined): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (Array.isArray(value)) {
for (const entry of value) {
const normalized = this.normalizeTokenValue(entry);
if (normalized) {
return normalized;
}
}
}
return null;
}
private pickFirstString(...values: Array<string | undefined | null>): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.length > 0) {
return value;
}
}
return undefined;
}
}

View File

@ -16,6 +16,15 @@ export interface CsrfValidationResult {
reason?: string;
}
export interface CsrfTokenStats {
mode: "stateless";
totalTokens: number;
activeTokens: number;
expiredTokens: number;
cacheSize: number;
maxCacheSize: number;
}
/**
* Service for CSRF token generation and validation using deterministic HMAC tokens.
*/
@ -133,12 +142,14 @@ export class CsrfService {
this.logger.debug("invalidateUserTokens called - rotate cookie to enforce");
}
getTokenStats() {
getTokenStats(): CsrfTokenStats {
return {
mode: "stateless",
totalTokens: 0,
activeTokens: 0,
expiredTokens: 0,
cacheSize: 0,
maxCacheSize: 0,
};
}

View File

@ -418,8 +418,8 @@ export class SecureErrorMapperService {
/database|sql|connection/i,
/file|path|directory/i,
/\s+at\s+.*\.js:\d+/i, // Stack traces
/[a-zA-Z]:[\\\/]/, // Windows paths
/[/][a-zA-Z0-9._\-/]+\.(js|ts|py|php)/i, // Unix paths
/[a-zA-Z]:[\\/]/, // Windows paths
/\/[a-zA-Z0-9._\-/]+\.(js|ts|py|php)/i, // Unix paths
/\b(?:\d{1,3}\.){3}\d{1,3}\b/, // IP addresses
/[A-Za-z0-9]{32,}/, // Long tokens/hashes
];
@ -461,7 +461,7 @@ export class SecureErrorMapperService {
// Remove stack traces
.replace(/\s+at\s+.*/g, "")
// Remove absolute paths
.replace(/[a-zA-Z]:[\\\/][^:]+/g, "[path]")
.replace(/[a-zA-Z]:[\\/][^:]+/g, "[path]")
// Remove IP addresses
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]")
// Remove URLs with credentials

View File

@ -2,6 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Inject } from "@nest
import type { Request, Response } from "express";
import { Logger } from "nestjs-pino";
import { ZodValidationException } from "nestjs-zod";
import type { ZodError, ZodIssue } from "zod";
interface ZodIssueResponse {
path: string;
@ -18,12 +19,18 @@ export class ZodValidationExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const zodError = exception.getZodError();
const issues: ZodIssueResponse[] = zodError.issues.map(issue => ({
path: issue.path.join(".") || "root",
message: issue.message,
code: issue.code,
}));
const rawZodError = exception.getZodError();
let issues: ZodIssueResponse[] = [];
if (!this.isZodError(rawZodError)) {
this.logger.error("ZodValidationException did not contain a ZodError", {
path: request.url,
method: request.method,
providedType: typeof rawZodError,
});
} else {
issues = this.mapIssues(rawZodError.issues);
}
this.logger.warn("Request validation failed", {
path: request.url,
@ -42,4 +49,18 @@ export class ZodValidationExceptionFilter implements ExceptionFilter {
path: request.url,
});
}
private isZodError(error: unknown): error is ZodError {
return Boolean(
error && typeof error === "object" && Array.isArray((error as { issues?: unknown }).issues)
);
}
private mapIssues(issues: ZodIssue[]): ZodIssueResponse[] {
return issues.map(issue => ({
path: issue.path.join(".") || "root",
message: issue.message,
code: issue.code,
}));
}
}

View File

@ -13,11 +13,11 @@ import {
WhmcsCapturePaymentParams,
} from "../types/whmcs-api.types";
export interface InvoiceFilters {
status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
page?: number;
limit?: number;
}
export type InvoiceFilters = Partial<{
status: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
page: number;
limit: number;
}>;
@Injectable()
export class WhmcsInvoiceService {
@ -62,26 +62,15 @@ export class WhmcsInvoiceService {
const response = await this.connectionService.getInvoices(params);
const transformed = this.transformInvoicesResponse(response, clientId, page, limit);
const parseResult = invoiceListSchema.safeParse(transformed);
if (!parseResult.success) {
this.logger.error("Failed to parse invoices response", {
error: parseResult.error.issues,
clientId,
page,
limit,
});
throw new Error("Invalid invoice response from WHMCS");
}
const result = parseResult.data;
const result = invoiceListSchema.parse(transformed);
// Cache the result
await this.cacheService.setInvoicesList(userId, page, limit, status, result as any);
await this.cacheService.setInvoicesList(userId, page, limit, status, result);
this.logger.log(
`Fetched ${result.invoices.length} invoices for client ${clientId}, page ${page}`
);
return result as any;
return result;
} catch (error) {
this.logger.error(`Failed to fetch invoices for client ${clientId}`, {
error: getErrorMessage(error),
@ -110,16 +99,7 @@ export class WhmcsInvoiceService {
try {
// Get detailed invoice with items
const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id);
const parseResult = invoiceSchema.safeParse(detailedInvoice);
if (!parseResult.success) {
this.logger.error("Failed to parse detailed invoice", {
error: parseResult.error.issues,
invoiceId: invoice.id,
clientId,
});
throw new Error("Invalid invoice data");
}
return parseResult.data;
return invoiceSchema.parse(detailedInvoice);
} catch (error) {
this.logger.warn(
`Failed to fetch details for invoice ${invoice.id}`,
@ -132,7 +112,7 @@ export class WhmcsInvoiceService {
);
const result: InvoiceList = {
invoices: invoicesWithItems as any,
invoices: invoicesWithItems,
pagination: invoiceList.pagination,
};

View File

@ -6,15 +6,21 @@ import {
UseGuards,
Query,
BadRequestException,
UsePipes,
} from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
import { AdminGuard } from "./guards/admin.guard";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
import { UsersService } from "@bff/modules/users/users.service";
import { TokenMigrationService } from "@bff/modules/auth/infra/token/token-migration.service";
import { ZodValidationPipe } from "@bff/core/validation";
import {
auditLogQuerySchema,
dryRunQuerySchema,
type AuditLogQuery,
type DryRunQuery,
} from "@customer-portal/domain";
import { z } from "zod";
@ApiTags("auth-admin")
@ApiBearerAuth()
@UseGuards(AdminGuard)
@Controller("auth/admin")
export class AuthAdminController {
@ -25,41 +31,27 @@ export class AuthAdminController {
) {}
@Get("audit-logs")
@ApiOperation({ summary: "Get audit logs (admin only)" })
@ApiResponse({ status: 200, description: "Audit logs retrieved" })
async getAuditLogs(
@Query("page") page: string = "1",
@Query("limit") limit: string = "50",
@Query("action") action?: AuditAction,
@Query("userId") userId?: string
) {
const pageNum = parseInt(page, 10);
const limitNum = parseInt(limit, 10);
if (Number.isNaN(pageNum) || Number.isNaN(limitNum) || pageNum < 1 || limitNum < 1) {
throw new BadRequestException("Invalid pagination parameters");
}
@UsePipes(new ZodValidationPipe(auditLogQuerySchema))
async getAuditLogs(@Query() query: AuditLogQuery) {
const { logs, total } = await this.auditService.getAuditLogs({
page: pageNum,
limit: limitNum,
action,
userId,
page: query.page,
limit: query.limit,
action: query.action as AuditAction | undefined,
userId: query.userId,
});
return {
logs,
pagination: {
page: pageNum,
limit: limitNum,
page: query.page,
limit: query.limit,
total,
totalPages: Math.ceil(total / limitNum),
totalPages: Math.ceil(total / query.limit),
},
};
}
@Post("unlock-account/:userId")
@ApiOperation({ summary: "Unlock user account (admin only)" })
@ApiResponse({ status: 200, description: "Account unlocked" })
async unlockAccount(@Param("userId") userId: string) {
const user = await this.usersService.findById(userId);
if (!user) {
@ -83,24 +75,19 @@ export class AuthAdminController {
}
@Get("security-stats")
@ApiOperation({ summary: "Get security statistics (admin only)" })
@ApiResponse({ status: 200, description: "Security stats retrieved" })
async getSecurityStats() {
return this.auditService.getSecurityStats();
}
@Get("token-migration/status")
@ApiOperation({ summary: "Get token migration status (admin only)" })
@ApiResponse({ status: 200, description: "Migration status retrieved" })
async getTokenMigrationStatus() {
return this.tokenMigrationService.getMigrationStatus();
}
@Post("token-migration/run")
@ApiOperation({ summary: "Run token migration (admin only)" })
@ApiResponse({ status: 200, description: "Migration completed" })
async runTokenMigration(@Query("dryRun") dryRun: string = "true") {
const isDryRun = dryRun.toLowerCase() !== "false";
@UsePipes(new ZodValidationPipe(dryRunQuerySchema))
async runTokenMigration(@Query() query: DryRunQuery) {
const isDryRun = query.dryRun ?? true;
const stats = await this.tokenMigrationService.migrateExistingTokens(isDryRun);
await this.auditService.log({
@ -121,10 +108,9 @@ export class AuthAdminController {
}
@Post("token-migration/cleanup")
@ApiOperation({ summary: "Clean up orphaned tokens (admin only)" })
@ApiResponse({ status: 200, description: "Cleanup completed" })
async cleanupOrphanedTokens(@Query("dryRun") dryRun: string = "true") {
const isDryRun = dryRun.toLowerCase() !== "false";
@UsePipes(new ZodValidationPipe(dryRunQuerySchema))
async cleanupOrphanedTokens(@Query() query: DryRunQuery) {
const isDryRun = query.dryRun ?? true;
const stats = await this.tokenMigrationService.cleanupOrphanedTokens(isDryRun);
await this.auditService.log({

View File

@ -17,7 +17,6 @@ import { LocalAuthGuard } from "./guards/local-auth.guard";
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from "@nestjs/swagger";
import { Public } from "./decorators/public.decorator";
import { ZodValidationPipe } from "@bff/core/validation";
@ -78,7 +77,6 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => {
return Math.max(0, expiresAt - Date.now());
};
@ApiTags("auth")
@Controller("auth")
export class AuthController {
constructor(private authFacade: AuthFacade) {}
@ -107,19 +105,12 @@ export class AuthController {
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 20, ttl: 600000 } }) // 20 validations per 10 minutes per IP
@UsePipes(new ZodValidationPipe(validateSignupRequestSchema))
@ApiOperation({ summary: "Validate customer number for signup" })
@ApiResponse({ status: 200, description: "Validation successful" })
@ApiResponse({ status: 409, description: "Customer already has account" })
@ApiResponse({ status: 400, description: "Customer number not found" })
@ApiResponse({ status: 429, description: "Too many validation attempts" })
async validateSignup(@Body() validateData: ValidateSignupRequestInput, @Req() req: Request) {
return this.authFacade.validateSignup(validateData, req);
}
@Public()
@Get("health-check")
@ApiOperation({ summary: "Check auth service health and integrations" })
@ApiResponse({ status: 200, description: "Health check results" })
async healthCheck() {
return this.authFacade.healthCheck();
}
@ -130,8 +121,6 @@ export class AuthController {
@Throttle({ default: { limit: 20, ttl: 600000 } }) // 20 validations per 10 minutes per IP
@UsePipes(new ZodValidationPipe(signupRequestSchema))
@HttpCode(200)
@ApiOperation({ summary: "Validate full signup data without creating anything" })
@ApiResponse({ status: 200, description: "Preflight results with next action guidance" })
async signupPreflight(@Body() signupData: SignupRequestInput) {
return this.authFacade.signupPreflight(signupData);
}
@ -139,8 +128,6 @@ export class AuthController {
@Public()
@Post("account-status")
@UsePipes(new ZodValidationPipe(accountStatusRequestSchema))
@ApiOperation({ summary: "Get account status by email" })
@ApiOkResponse({ description: "Account status" })
async accountStatus(@Body() body: AccountStatusRequestInput) {
return this.authFacade.getAccountStatus(body.email);
}
@ -150,10 +137,6 @@ export class AuthController {
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 signups per 15 minutes per IP (reasonable for account creation)
@UsePipes(new ZodValidationPipe(signupRequestSchema))
@ApiOperation({ summary: "Create new user account" })
@ApiResponse({ status: 201, description: "User created successfully" })
@ApiResponse({ status: 409, description: "User already exists" })
@ApiResponse({ status: 429, description: "Too many signup attempts" })
async signup(
@Body() signupData: SignupRequestInput,
@Req() req: Request,
@ -168,10 +151,6 @@ export class AuthController {
@UseGuards(LocalAuthGuard, FailedLoginThrottleGuard)
@UseInterceptors(LoginResultInterceptor)
@Post("login")
@ApiOperation({ summary: "Authenticate user" })
@ApiResponse({ status: 200, description: "Login successful" })
@ApiResponse({ status: 401, description: "Invalid credentials" })
@ApiResponse({ status: 429, description: "Too many login attempts" })
async login(
@Req() req: Request & { user: { id: string; email: string; role: string } },
@Res({ passthrough: true }) res: Response
@ -182,8 +161,6 @@ export class AuthController {
}
@Post("logout")
@ApiOperation({ summary: "Logout user" })
@ApiResponse({ status: 200, description: "Logout successful" })
async logout(
@Req() req: RequestWithCookies & { user: { id: string } },
@Res({ passthrough: true }) res: Response
@ -198,10 +175,6 @@ export class AuthController {
@Post("refresh")
@Throttle({ default: { limit: 10, ttl: 300000 } }) // 10 attempts per 5 minutes per IP
@UsePipes(new ZodValidationPipe(refreshTokenRequestSchema))
@ApiOperation({ summary: "Refresh access token using refresh token" })
@ApiResponse({ status: 200, description: "Token refreshed successfully" })
@ApiResponse({ status: 401, description: "Invalid refresh token" })
@ApiResponse({ status: 429, description: "Too many refresh attempts" })
async refreshToken(
@Body() body: RefreshTokenRequestInput,
@Req() req: RequestWithCookies,
@ -221,13 +194,6 @@ export class AuthController {
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 5, ttl: 600000 } }) // 5 attempts per 10 minutes per IP (industry standard)
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
@ApiOperation({ summary: "Link existing WHMCS user" })
@ApiResponse({
status: 200,
description: "WHMCS account linked successfully",
})
@ApiResponse({ status: 401, description: "Invalid WHMCS credentials" })
@ApiResponse({ status: 429, description: "Too many link attempts" })
async linkWhmcs(@Body() linkData: LinkWhmcsRequestInput, @Req() _req: Request) {
return this.authFacade.linkWhmcsUser(linkData);
}
@ -237,10 +203,6 @@ export class AuthController {
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 5, ttl: 600000 } }) // 5 attempts per 10 minutes per IP+UA (industry standard)
@UsePipes(new ZodValidationPipe(setPasswordRequestSchema))
@ApiOperation({ summary: "Set password for linked user" })
@ApiResponse({ status: 200, description: "Password set successfully" })
@ApiResponse({ status: 401, description: "User not found" })
@ApiResponse({ status: 429, description: "Too many password attempts" })
async setPassword(
@Body() setPasswordData: SetPasswordRequestInput,
@Req() _req: Request,
@ -255,8 +217,6 @@ export class AuthController {
@Post("check-password-needed")
@UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema))
@HttpCode(200)
@ApiOperation({ summary: "Check if user needs to set password" })
@ApiResponse({ status: 200, description: "Password status checked" })
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestInput) {
return this.authFacade.checkPasswordNeeded(data.email);
}
@ -265,8 +225,6 @@ export class AuthController {
@Post("request-password-reset")
@Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 attempts per 15 minutes (standard for password operations)
@UsePipes(new ZodValidationPipe(passwordResetRequestSchema))
@ApiOperation({ summary: "Request password reset email" })
@ApiResponse({ status: 200, description: "Reset email sent if account exists" })
async requestPasswordReset(@Body() body: PasswordResetRequestInput) {
await this.authFacade.requestPasswordReset(body.email, req);
return { message: "If an account exists, a reset email has been sent" };
@ -276,8 +234,6 @@ export class AuthController {
@Post("reset-password")
@Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 attempts per 15 minutes (standard for password operations)
@UsePipes(new ZodValidationPipe(passwordResetSchema))
@ApiOperation({ summary: "Reset password with token" })
@ApiResponse({ status: 200, description: "Password reset successful" })
async resetPassword(@Body() body: PasswordResetInput, @Res({ passthrough: true }) res: Response) {
const result = await this.authFacade.resetPassword(body.token, body.password);
this.setAuthCookies(res, result.tokens);
@ -287,8 +243,6 @@ export class AuthController {
@Post("change-password")
@Throttle({ default: { limit: 5, ttl: 300000 } })
@UsePipes(new ZodValidationPipe(changePasswordRequestSchema))
@ApiOperation({ summary: "Change password (authenticated)" })
@ApiResponse({ status: 200, description: "Password changed successfully" })
async changePassword(
@Req() req: Request & { user: { id: string } },
@Body() body: ChangePasswordRequestInput,
@ -305,7 +259,6 @@ export class AuthController {
}
@Get("me")
@ApiOperation({ summary: "Get current authentication status" })
getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role: string } }) {
// Return basic auth info only - full profile should use /api/me
return {
@ -320,12 +273,6 @@ export class AuthController {
@Post("sso-link")
@UsePipes(new ZodValidationPipe(ssoLinkRequestSchema))
@ApiOperation({ summary: "Create SSO link to WHMCS" })
@ApiResponse({ status: 200, description: "SSO link created successfully" })
@ApiResponse({
status: 404,
description: "User not found or not linked to WHMCS",
})
async createSsoLink(
@Req() req: Request & { user: { id: string } },
@Body() body: SsoLinkRequestInput

View File

@ -1,5 +1,4 @@
import { Controller, Get, Request } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import type {
InternetAddonCatalogItem,
InternetInstallationCatalogItem,
@ -12,9 +11,7 @@ import { InternetCatalogService } from "./services/internet-catalog.service";
import { SimCatalogService } from "./services/sim-catalog.service";
import { VpnCatalogService } from "./services/vpn-catalog.service";
@ApiTags("catalog")
@Controller("catalog")
@ApiBearerAuth()
export class CatalogController {
constructor(
private internetCatalog: InternetCatalogService,
@ -23,10 +20,7 @@ export class CatalogController {
) {}
@Get("internet/plans")
@ApiOperation({ summary: "Get Internet plans filtered by customer eligibility" })
async getInternetPlans(
@Request() req: { user: { id: string } }
): Promise<{
async getInternetPlans(@Request() req: { user: { id: string } }): Promise<{
plans: InternetPlanCatalogItem[];
installations: InternetInstallationCatalogItem[];
addons: InternetAddonCatalogItem[];
@ -36,31 +30,28 @@ export class CatalogController {
// Fallback to all catalog data if no user context
return this.internetCatalog.getCatalogData();
}
// Get user-specific plans but all installations and addons
const [plans, installations, addons] = await Promise.all([
this.internetCatalog.getPlansForUser(userId),
this.internetCatalog.getInstallations(),
this.internetCatalog.getAddons(),
]);
return { plans, installations, addons };
}
@Get("internet/addons")
@ApiOperation({ summary: "Get Internet add-ons" })
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
return this.internetCatalog.getAddons();
}
@Get("internet/installations")
@ApiOperation({ summary: "Get Internet installations" })
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
return this.internetCatalog.getInstallations();
}
@Get("sim/plans")
@ApiOperation({ summary: "Get SIM plans filtered by user's existing services" })
async getSimPlans(@Request() req: { user: { id: string } }): Promise<SimCatalogProduct[]> {
const userId = req.user?.id;
if (!userId) {
@ -72,25 +63,21 @@ export class CatalogController {
}
@Get("sim/activation-fees")
@ApiOperation({ summary: "Get SIM activation fees" })
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
return this.simCatalog.getActivationFees();
}
@Get("sim/addons")
@ApiOperation({ summary: "Get SIM add-ons" })
async getSimAddons(): Promise<SimCatalogProduct[]> {
return this.simCatalog.getAddons();
}
@Get("vpn/plans")
@ApiOperation({ summary: "Get VPN plans" })
async getVpnPlans(): Promise<VpnCatalogProduct[]> {
return this.vpnCatalog.getPlans();
}
@Get("vpn/activation-fees")
@ApiOperation({ summary: "Get VPN activation fees" })
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
return this.vpnCatalog.getActivationFees();
}

View File

@ -11,16 +11,6 @@ import {
BadRequestException,
UsePipes,
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiQuery,
ApiBearerAuth,
ApiParam,
} from "@nestjs/swagger";
import { createZodDto } from "nestjs-zod";
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
@ -36,23 +26,13 @@ import type {
InvoicePaymentLink,
InvoiceListQuery,
} from "@customer-portal/domain";
import {
invoiceSchema,
invoiceListSchema,
invoiceListQuerySchema,
} from "@customer-portal/domain";
// ✅ CLEAN: DTOs only for OpenAPI generation
class InvoiceDto extends createZodDto(invoiceSchema) {}
class InvoiceListDto extends createZodDto(invoiceListSchema) {}
import { invoiceListQuerySchema } from "@customer-portal/domain";
interface AuthenticatedRequest {
user: { id: string };
}
@ApiTags("invoices")
@Controller("invoices")
@ApiBearerAuth()
export class InvoicesController {
constructor(
private readonly invoicesService: InvoicesOrchestratorService,
@ -61,33 +41,6 @@ export class InvoicesController {
) {}
@Get()
@ApiOperation({
summary: "Get paginated list of user invoices",
description:
"Retrieves invoices for the authenticated user with pagination and optional status filtering",
})
@ApiQuery({
name: "page",
required: false,
type: Number,
description: "Page number (default: 1)",
})
@ApiQuery({
name: "limit",
required: false,
type: Number,
description: "Items per page (default: 10)",
})
@ApiQuery({
name: "status",
required: false,
type: String,
description: "Filter by invoice status",
})
@ApiOkResponse({
description: "List of invoices with pagination",
type: InvoiceListDto
})
@UsePipes(new ZodValidationPipe(invoiceListQuerySchema))
async getInvoices(
@Request() req: AuthenticatedRequest,
@ -97,11 +50,6 @@ export class InvoicesController {
}
@Get("payment-methods")
@ApiOperation({
summary: "Get user payment methods",
description: "Retrieves all saved payment methods for the authenticated user",
})
@ApiOkResponse({ description: "List of payment methods" })
async getPaymentMethods(@Request() req: AuthenticatedRequest): Promise<PaymentMethodList> {
const mapping = await this.mappingsService.findByUserId(req.user.id);
if (!mapping?.whmcsClientId) {
@ -111,22 +59,12 @@ export class InvoicesController {
}
@Get("payment-gateways")
@ApiOperation({
summary: "Get available payment gateways",
description: "Retrieves all active payment gateways available for payments",
})
@ApiOkResponse({ description: "List of payment gateways" })
async getPaymentGateways(): Promise<PaymentGatewayList> {
return this.whmcsService.getPaymentGateways();
}
@Post("payment-methods/refresh")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Refresh payment methods cache",
description: "Invalidates and refreshes payment methods cache for the current user",
})
@ApiOkResponse({ description: "Payment methods cache refreshed" })
async refreshPaymentMethods(@Request() req: AuthenticatedRequest): Promise<PaymentMethodList> {
// Invalidate cache first
await this.whmcsService.invalidatePaymentMethodsCache(req.user.id);
@ -140,16 +78,6 @@ export class InvoicesController {
}
@Get(":id")
@ApiOperation({
summary: "Get invoice details by ID",
description: "Retrieves detailed information for a specific invoice",
})
@ApiParam({ name: "id", type: Number, description: "Invoice ID" })
@ApiOkResponse({
description: "Invoice details",
type: InvoiceDto
})
@ApiResponse({ status: 404, description: "Invoice not found" })
async getInvoiceById(
@Request() req: AuthenticatedRequest,
@Param("id", ParseIntPipe) invoiceId: number
@ -162,13 +90,6 @@ export class InvoicesController {
}
@Get(":id/subscriptions")
@ApiOperation({
summary: "Get subscriptions related to an invoice",
description: "Retrieves all subscriptions that are referenced in the invoice items",
})
@ApiParam({ name: "id", type: Number, description: "Invoice ID" })
@ApiOkResponse({ description: "List of related subscriptions" })
@ApiResponse({ status: 404, description: "Invoice not found" })
getInvoiceSubscriptions(
@Request() req: AuthenticatedRequest,
@Param("id", ParseIntPipe) invoiceId: number
@ -184,19 +105,6 @@ export class InvoicesController {
@Post(":id/sso-link")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Create SSO link for invoice",
description: "Generates a single sign-on link to view/pay the invoice or download PDF in WHMCS",
})
@ApiParam({ name: "id", type: Number, description: "Invoice ID" })
@ApiQuery({
name: "target",
required: false,
enum: ["view", "download", "pay"],
description: "Link target: view invoice, download PDF, or go to payment page (default: view)",
})
@ApiOkResponse({ description: "SSO link created successfully" })
@ApiResponse({ status: 404, description: "Invoice not found" })
async createSsoLink(
@Request() req: AuthenticatedRequest,
@Param("id", ParseIntPipe) invoiceId: number,
@ -230,26 +138,6 @@ export class InvoicesController {
@Post(":id/payment-link")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Create payment link for invoice with payment method",
description:
"Generates a payment link for the invoice with a specific payment method or gateway",
})
@ApiParam({ name: "id", type: Number, description: "Invoice ID" })
@ApiQuery({
name: "paymentMethodId",
required: false,
type: Number,
description: "Payment method ID",
})
@ApiQuery({
name: "gatewayName",
required: false,
type: String,
description: "Payment gateway name",
})
@ApiOkResponse({ description: "Payment link created successfully" })
@ApiResponse({ status: 404, description: "Invoice not found" })
async createPaymentLink(
@Request() req: AuthenticatedRequest,
@Param("id", ParseIntPipe) invoiceId: number,

View File

@ -1,12 +1,15 @@
import { Body, Controller, Get, Param, Post, Request, UsePipes } from "@nestjs/common";
import { OrderOrchestrator } from "./services/order-orchestrator.service";
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import { Logger } from "nestjs-pino";
import { ZodValidationPipe } from "@bff/core/validation";
import { createOrderRequestSchema, type CreateOrderRequest } from "@customer-portal/domain";
import {
createOrderRequestSchema,
sfOrderIdParamSchema,
type CreateOrderRequest,
type SfOrderIdParam,
} from "@customer-portal/domain";
@ApiTags("orders")
@Controller("orders")
export class OrdersController {
constructor(
@ -14,12 +17,8 @@ export class OrdersController {
private readonly logger: Logger
) {}
@ApiBearerAuth()
@Post()
@UsePipes(new ZodValidationPipe(createOrderRequestSchema))
@ApiOperation({ summary: "Create Salesforce Order" })
@ApiResponse({ status: 201, description: "Order created successfully" })
@ApiResponse({ status: 400, description: "Invalid request data" })
async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequest) {
this.logger.log(
{
@ -45,19 +44,15 @@ export class OrdersController {
}
}
@ApiBearerAuth()
@Get("user")
@ApiOperation({ summary: "Get user's orders" })
async getUserOrders(@Request() req: RequestWithUser) {
return this.orderOrchestrator.getOrdersForUser(req.user.id);
}
@ApiBearerAuth()
@Get(":sfOrderId")
@ApiOperation({ summary: "Get order summary/status" })
@ApiParam({ name: "sfOrderId", type: String })
async get(@Request() req: RequestWithUser, @Param("sfOrderId") sfOrderId: string) {
return this.orderOrchestrator.getOrder(sfOrderId);
@UsePipes(new ZodValidationPipe(sfOrderIdParamSchema))
async get(@Request() req: RequestWithUser, @Param() params: SfOrderIdParam) {
return this.orderOrchestrator.getOrder(params.sfOrderId);
}
// Note: Order provisioning has been moved to SalesforceProvisioningController

View File

@ -6,6 +6,8 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SalesforceOrderRecord } from "@customer-portal/domain";
import { SalesforceFieldMapService, type SalesforceFieldMap } from "@bff/core/config/field-map";
import { orderFulfillmentValidationSchema } from "@customer-portal/domain";
import { sfOrderIdParamSchema } from "@customer-portal/domain";
type OrderStringFieldKey = "activationStatus";
export interface OrderFulfillmentValidationResult {

View File

@ -2,6 +2,9 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nes
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { OrderPricebookService } from "./order-pricebook.service";
import type { PrismaService } from "@bff/infra/database/prisma.service";
import { z } from "zod";
import { createOrderRequestSchema } from "@customer-portal/domain";
/**
* Handles building order items from SKU data
@ -11,7 +14,8 @@ export class OrderItemBuilder {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly sf: SalesforceConnection,
private readonly pricebookService: OrderPricebookService
private readonly pricebookService: OrderPricebookService,
private readonly prisma: PrismaService
) {}
/**
@ -22,8 +26,9 @@ export class OrderItemBuilder {
skus: string[],
pricebookId: string
): Promise<void> {
if (skus.length === 0) {
throw new BadRequestException("No products specified for order");
const { skus: validatedSkus } = buildItemsSchema.parse({ skus });
if (pricebookId.length === 0) {
throw new BadRequestException("Product SKU is required");
}
this.logger.log({ orderId, skus }, "Creating OrderItems from SKU array");
@ -31,7 +36,7 @@ export class OrderItemBuilder {
const metaMap = await this.pricebookService.fetchProductMeta(pricebookId, skus);
// Create OrderItems for each SKU
for (const sku of skus) {
for (const sku of validatedSkus) {
const normalizedSkuValue = sku?.trim();
if (!normalizedSkuValue) {
this.logger.error({ orderId }, "Encountered empty SKU while creating order items");
@ -125,3 +130,5 @@ export class OrderItemBuilder {
};
}
}
const buildItemsSchema = createOrderRequestSchema.pick({ skus: true });

View File

@ -1,19 +1,13 @@
import { Body, Controller, Post, Request } from "@nestjs/common";
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import { SimOrderActivationService } from "./sim-order-activation.service";
import type { SimOrderActivationRequest } from "./sim-order-activation.service";
@ApiTags("sim-orders")
@ApiBearerAuth()
@Controller("subscriptions/sim/orders")
export class SimOrdersController {
constructor(private readonly activation: SimOrderActivationService) {}
@Post("activate")
@ApiOperation({ summary: "Create invoice, capture payment, and activate SIM in Freebit" })
@ApiBody({ description: "SIM activation order payload" })
@ApiResponse({ status: 200, description: "Activation processed" })
async activate(@Request() req: RequestWithUser, @Body() body: SimOrderActivationRequest) {
const result = await this.activation.activate(req.user.id, body);
return result;

View File

@ -10,16 +10,6 @@ import {
BadRequestException,
UsePipes,
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiQuery,
ApiBearerAuth,
ApiParam,
ApiBody,
} from "@nestjs/swagger";
import { SubscriptionsService } from "./subscriptions.service";
import { SimManagementService } from "./sim-management.service";
@ -43,9 +33,7 @@ import {
import { ZodValidationPipe } from "@bff/core/validation";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
@ApiTags("subscriptions")
@Controller("subscriptions")
@ApiBearerAuth()
export class SubscriptionsController {
constructor(
private readonly subscriptionsService: SubscriptionsService,
@ -53,17 +41,6 @@ export class SubscriptionsController {
) {}
@Get()
@ApiOperation({
summary: "Get all user subscriptions",
description: "Retrieves all subscriptions/services for the authenticated user",
})
@ApiQuery({
name: "status",
required: false,
type: String,
description: "Filter by subscription status",
})
@ApiOkResponse({ description: "List of user subscriptions" })
@UsePipes(new ZodValidationPipe(subscriptionQuerySchema))
async getSubscriptions(
@Request() req: RequestWithUser,
@ -77,21 +54,11 @@ export class SubscriptionsController {
}
@Get("active")
@ApiOperation({
summary: "Get active subscriptions only",
description: "Retrieves only active subscriptions for the authenticated user",
})
@ApiOkResponse({ description: "List of active subscriptions" })
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
return this.subscriptionsService.getActiveSubscriptions(req.user.id);
}
@Get("stats")
@ApiOperation({
summary: "Get subscription statistics",
description: "Retrieves subscription count statistics by status",
})
@ApiOkResponse({ description: "Subscription statistics" })
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{
total: number;
active: number;
@ -103,13 +70,6 @@ export class SubscriptionsController {
}
@Get(":id")
@ApiOperation({
summary: "Get subscription details by ID",
description: "Retrieves detailed information for a specific subscription",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiOkResponse({ description: "Subscription details" })
@ApiResponse({ status: 404, description: "Subscription not found" })
async getSubscriptionById(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
@ -122,25 +82,6 @@ export class SubscriptionsController {
}
@Get(":id/invoices")
@ApiOperation({
summary: "Get invoices for a specific subscription",
description: "Retrieves all invoices related to a specific subscription",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiQuery({
name: "page",
required: false,
type: Number,
description: "Page number (default: 1)",
})
@ApiQuery({
name: "limit",
required: false,
type: Number,
description: "Items per page (default: 10)",
})
@ApiOkResponse({ description: "List of invoices for the subscription" })
@ApiResponse({ status: 404, description: "Subscription not found" })
@UsePipes(new ZodValidationPipe(invoiceListQuerySchema))
async getSubscriptionInvoices(
@Request() req: RequestWithUser,
@ -157,12 +98,6 @@ export class SubscriptionsController {
// ==================== SIM Management Endpoints ====================
@Get(":id/sim/debug")
@ApiOperation({
summary: "Debug SIM subscription data",
description: "Retrieves subscription data to help debug SIM management issues",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiResponse({ status: 200, description: "Subscription debug data" })
async debugSimSubscription(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
@ -171,14 +106,6 @@ export class SubscriptionsController {
}
@Get(":id/sim")
@ApiOperation({
summary: "Get SIM details and usage",
description: "Retrieves comprehensive SIM information including details and current usage",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiResponse({ status: 200, description: "SIM information" })
@ApiResponse({ status: 400, description: "Not a SIM subscription" })
@ApiResponse({ status: 404, description: "Subscription not found" })
async getSimInfo(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
@ -187,12 +114,6 @@ export class SubscriptionsController {
}
@Get(":id/sim/details")
@ApiOperation({
summary: "Get SIM details",
description: "Retrieves detailed SIM information including ICCID, plan, status, etc.",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiResponse({ status: 200, description: "SIM details" })
async getSimDetails(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
@ -201,12 +122,6 @@ export class SubscriptionsController {
}
@Get(":id/sim/usage")
@ApiOperation({
summary: "Get SIM data usage",
description: "Retrieves current data usage and recent usage history",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiResponse({ status: 200, description: "SIM usage data" })
async getSimUsage(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
@ -215,14 +130,6 @@ export class SubscriptionsController {
}
@Get(":id/sim/top-up-history")
@ApiOperation({
summary: "Get SIM top-up history",
description: "Retrieves data top-up history for the specified date range",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiQuery({ name: "fromDate", description: "Start date (YYYYMMDD)", example: "20240101" })
@ApiQuery({ name: "toDate", description: "End date (YYYYMMDD)", example: "20241231" })
@ApiResponse({ status: 200, description: "Top-up history" })
async getSimTopUpHistory(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@ -241,22 +148,6 @@ export class SubscriptionsController {
@Post(":id/sim/top-up")
@UsePipes(new ZodValidationPipe(simTopupRequestSchema))
@ApiOperation({
summary: "Top up SIM data quota",
description: "Add data quota to the SIM service",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiBody({
description: "Top-up request",
schema: {
type: "object",
properties: {
quotaMb: { type: "number", description: "Quota in MB", example: 1000 },
},
required: ["quotaMb"],
},
})
@ApiResponse({ status: 200, description: "Top-up successful" })
async topUpSim(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@ -268,23 +159,6 @@ export class SubscriptionsController {
@Post(":id/sim/change-plan")
@UsePipes(new ZodValidationPipe(simChangePlanRequestSchema))
@ApiOperation({
summary: "Change SIM plan",
description:
"Change the SIM service plan. The change will be automatically scheduled for the 1st of the next month.",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiBody({
description: "Plan change request",
schema: {
type: "object",
properties: {
newPlanCode: { type: "string", description: "New plan code", example: "LTE3G_P01" },
},
required: ["newPlanCode"],
},
})
@ApiResponse({ status: 200, description: "Plan change successful" })
async changeSimPlan(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@ -300,26 +174,6 @@ export class SubscriptionsController {
@Post(":id/sim/cancel")
@UsePipes(new ZodValidationPipe(simCancelRequestSchema))
@ApiOperation({
summary: "Cancel SIM service",
description: "Cancel the SIM service (immediate or scheduled)",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiBody({
description: "Cancellation request",
schema: {
type: "object",
properties: {
scheduledAt: {
type: "string",
description: "Schedule cancellation (YYYYMMDD)",
example: "20241231",
},
},
},
required: false,
})
@ApiResponse({ status: 200, description: "Cancellation successful" })
async cancelSim(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@ -330,28 +184,6 @@ export class SubscriptionsController {
}
@Post(":id/sim/reissue-esim")
@ApiOperation({
summary: "Reissue eSIM profile",
description:
"Reissue a downloadable eSIM profile (eSIM only). Optionally provide a new EID to transfer to.",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiBody({
description: "Optional new EID to transfer the eSIM to",
schema: {
type: "object",
properties: {
newEid: {
type: "string",
description: "32-digit EID",
example: "89049032000001000000043598005455",
},
},
required: [],
},
})
@ApiResponse({ status: 200, description: "eSIM reissue successful" })
@ApiResponse({ status: 400, description: "Not an eSIM subscription" })
async reissueEsimProfile(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@ -363,25 +195,6 @@ export class SubscriptionsController {
@Post(":id/sim/features")
@UsePipes(new ZodValidationPipe(simFeaturesRequestSchema))
@ApiOperation({
summary: "Update SIM features",
description:
"Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)",
})
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiBody({
description: "Features update request",
schema: {
type: "object",
properties: {
voiceMailEnabled: { type: "boolean" },
callWaitingEnabled: { type: "boolean" },
internationalRoamingEnabled: { type: "boolean" },
networkType: { type: "string", enum: ["4G", "5G"] },
},
},
})
@ApiResponse({ status: 200, description: "Features update successful" })
async updateSimFeatures(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,

View File

@ -9,7 +9,6 @@ import {
UsePipes,
} from "@nestjs/common";
import { UsersService } from "./users.service";
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
import { ZodValidationPipe } from "@bff/core/validation";
import {
updateProfileRequestSchema,
@ -19,43 +18,28 @@ import {
} from "@customer-portal/domain";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
@ApiTags("users")
@Controller("me")
@ApiBearerAuth()
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
constructor(private usersService: UsersService) {}
@Get()
@ApiOperation({ summary: "Get current user profile" })
@ApiResponse({ status: 200, description: "User profile retrieved successfully" })
@ApiResponse({ status: 401, description: "Unauthorized" })
async getProfile(@Req() req: RequestWithUser) {
return this.usersService.findById(req.user.id);
}
@Get("summary")
@ApiOperation({ summary: "Get user dashboard summary" })
@ApiResponse({ status: 200, description: "User summary retrieved successfully" })
@ApiResponse({ status: 401, description: "Unauthorized" })
async getSummary(@Req() req: RequestWithUser) {
return this.usersService.getUserSummary(req.user.id);
}
@Patch()
@UsePipes(new ZodValidationPipe(updateProfileRequestSchema))
@ApiOperation({ summary: "Update user profile" })
@ApiResponse({ status: 200, description: "Profile updated successfully" })
@ApiResponse({ status: 400, description: "Invalid input data" })
@ApiResponse({ status: 401, description: "Unauthorized" })
async updateProfile(@Req() req: RequestWithUser, @Body() updateData: UpdateProfileRequest) {
return this.usersService.update(req.user.id, updateData);
}
@Get("address")
@ApiOperation({ summary: "Get mailing address" })
@ApiResponse({ status: 200, description: "Address retrieved successfully" })
@ApiResponse({ status: 401, description: "Unauthorized" })
async getAddress(@Req() req: RequestWithUser) {
return this.usersService.getAddress(req.user.id);
}
@ -64,10 +48,6 @@ export class UsersController {
@Patch("address")
@UsePipes(new ZodValidationPipe(updateAddressRequestSchema))
@ApiOperation({ summary: "Update mailing address" })
@ApiResponse({ status: 200, description: "Address updated successfully" })
@ApiResponse({ status: 400, description: "Invalid input data" })
@ApiResponse({ status: 401, description: "Unauthorized" })
async updateAddress(@Req() req: RequestWithUser, @Body() address: UpdateAddressRequest) {
await this.usersService.updateAddress(req.user.id, address);
// Return fresh address snapshot

View File

@ -29,7 +29,6 @@
"date-fns": "^4.1.0",
"lucide-react": "^0.540.0",
"next": "15.5.0",
"openapi-fetch": "^0.13.5",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-hook-form": "^7.62.0",

View File

@ -1,478 +0,0 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/minimal": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Minimal endpoint for OpenAPI generation */
get: operations["MinimalController_getMinimal"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/invoices": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get paginated list of user invoices
* @description Retrieves invoices for the authenticated user with pagination and optional status filtering
*/
get: operations["InvoicesController_getInvoices"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/invoices/payment-methods": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get user payment methods
* @description Retrieves all saved payment methods for the authenticated user
*/
get: operations["InvoicesController_getPaymentMethods"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/invoices/payment-gateways": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get available payment gateways
* @description Retrieves all active payment gateways available for payments
*/
get: operations["InvoicesController_getPaymentGateways"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/invoices/payment-methods/refresh": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Refresh payment methods cache
* @description Invalidates and refreshes payment methods cache for the current user
*/
post: operations["InvoicesController_refreshPaymentMethods"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/invoices/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get invoice details by ID
* @description Retrieves detailed information for a specific invoice
*/
get: operations["InvoicesController_getInvoiceById"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/invoices/{id}/subscriptions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get subscriptions related to an invoice
* @description Retrieves all subscriptions that are referenced in the invoice items
*/
get: operations["InvoicesController_getInvoiceSubscriptions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/invoices/{id}/sso-link": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Create SSO link for invoice
* @description Generates a single sign-on link to view/pay the invoice or download PDF in WHMCS
*/
post: operations["InvoicesController_createSsoLink"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/invoices/{id}/payment-link": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Create payment link for invoice with payment method
* @description Generates a payment link for the invoice with a specific payment method or gateway
*/
post: operations["InvoicesController_createPaymentLink"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
InvoiceListDto: {
invoices: {
id: number;
number: string;
/** @enum {string} */
status: "Draft" | "Pending" | "Paid" | "Unpaid" | "Overdue" | "Cancelled" | "Refunded" | "Collections";
currency: string;
currencySymbol?: string;
total: number;
subtotal: number;
tax: number;
issuedAt?: string;
dueDate?: string;
paidDate?: string;
pdfUrl?: string;
paymentUrl?: string;
description?: string;
items?: {
id: number;
description: string;
amount: number;
quantity?: number;
type: string;
serviceId?: number;
}[];
daysOverdue?: number;
}[];
pagination: {
page: number;
totalPages: number;
totalItems: number;
nextCursor?: string;
};
};
InvoiceDto: {
id: number;
number: string;
/** @enum {string} */
status: "Draft" | "Pending" | "Paid" | "Unpaid" | "Overdue" | "Cancelled" | "Refunded" | "Collections";
currency: string;
currencySymbol?: string;
total: number;
subtotal: number;
tax: number;
issuedAt?: string;
dueDate?: string;
paidDate?: string;
pdfUrl?: string;
paymentUrl?: string;
description?: string;
items?: {
id: number;
description: string;
amount: number;
quantity?: number;
type: string;
serviceId?: number;
}[];
daysOverdue?: number;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
MinimalController_getMinimal: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
InvoicesController_getInvoices: {
parameters: {
query?: {
/** @description Filter by invoice status */
status?: string;
/** @description Items per page (default: 10) */
limit?: number;
/** @description Page number (default: 1) */
page?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description List of invoices with pagination */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["InvoiceListDto"];
};
};
};
};
InvoicesController_getPaymentMethods: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description List of payment methods */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
InvoicesController_getPaymentGateways: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description List of payment gateways */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
InvoicesController_refreshPaymentMethods: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Payment methods cache refreshed */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
InvoicesController_getInvoiceById: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Invoice ID */
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Invoice details */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["InvoiceDto"];
};
};
/** @description Invoice not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
InvoicesController_getInvoiceSubscriptions: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Invoice ID */
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description List of related subscriptions */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invoice not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
InvoicesController_createSsoLink: {
parameters: {
query?: {
/** @description Link target: view invoice, download PDF, or go to payment page (default: view) */
target?: "view" | "download" | "pay";
};
header?: never;
path: {
/** @description Invoice ID */
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description SSO link created successfully */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invoice not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
InvoicesController_createPaymentLink: {
parameters: {
query?: {
/** @description Payment gateway name */
gatewayName?: string;
/** @description Payment method ID */
paymentMethodId?: number;
};
header?: never;
path: {
/** @description Invoice ID */
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Payment link created successfully */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invoice not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

View File

@ -1,4 +1,3 @@
export * as ApiTypes from "./__generated__/types";
export { createClient, resolveBaseUrl } from "./runtime/client";
export type { ApiClient, AuthHeaderResolver, CreateClientOptions } from "./runtime/client";
export { ApiError, isApiError } from "./runtime/client";

View File

@ -1,7 +1,5 @@
import createOpenApiClient from "openapi-fetch";
import type { Middleware, MiddlewareCallbackParams } from "openapi-fetch";
import type { paths } from "../__generated__/types";
import type { ApiResponse } from "../response-helpers";
export class ApiError extends Error {
constructor(
message: string,
@ -15,20 +13,46 @@ export class ApiError extends Error {
export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError;
type StrictApiClient = ReturnType<typeof createOpenApiClient<paths>>;
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type FlexibleApiMethods = {
GET<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>;
POST<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>;
PUT<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>;
PATCH<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>;
DELETE<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>;
};
type PathParams = Record<string, string | number>;
type QueryPrimitive = string | number | boolean;
type QueryParams = Record<
string,
QueryPrimitive | QueryPrimitive[] | readonly QueryPrimitive[] | undefined
>;
export type ApiClient = StrictApiClient & FlexibleApiMethods;
export interface RequestOptions {
params?: {
path?: PathParams;
query?: QueryParams;
};
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
credentials?: RequestCredentials;
disableCsrf?: boolean;
}
export type AuthHeaderResolver = () => string | undefined;
export interface CreateClientOptions {
baseUrl?: string;
getAuthHeader?: AuthHeaderResolver;
handleError?: (response: Response) => void | Promise<void>;
enableCsrf?: boolean;
}
type ApiMethod = <T = unknown>(path: string, options?: RequestOptions) => Promise<ApiResponse<T>>;
export interface ApiClient {
GET: ApiMethod;
POST: ApiMethod;
PUT: ApiMethod;
PATCH: ApiMethod;
DELETE: ApiMethod;
}
type EnvKey =
| "NEXT_PUBLIC_API_BASE"
| "NEXT_PUBLIC_API_URL"
@ -56,7 +80,6 @@ const normalizeBaseUrl = (value: string) => {
return trimmed;
}
// Avoid accidental double slashes when openapi-fetch joins with request path
return trimmed.replace(/\/+$/, "");
};
@ -81,19 +104,56 @@ export const resolveBaseUrl = (baseUrl?: string) => {
return resolveBaseUrlFromEnv();
};
export interface CreateClientOptions {
baseUrl?: string;
getAuthHeader?: AuthHeaderResolver;
handleError?: (response: Response) => void | Promise<void>;
enableCsrf?: boolean;
}
const applyPathParams = (path: string, params?: PathParams): string => {
if (!params) {
return path;
}
return path.replace(/\{([^}]+)\}/g, (_match, rawKey) => {
const key = rawKey as keyof typeof params;
if (!(key in params)) {
throw new Error(`Missing path parameter: ${String(rawKey)}`);
}
const value = params[key];
return encodeURIComponent(String(value));
});
};
const buildQueryString = (query?: QueryParams): string => {
if (!query) {
return "";
}
const searchParams = new URLSearchParams();
const appendPrimitive = (key: string, value: QueryPrimitive) => {
searchParams.append(key, String(value));
};
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
value.forEach(entry => appendPrimitive(key, entry));
continue;
}
appendPrimitive(key, value);
}
return searchParams.toString();
};
const getBodyMessage = (body: unknown): string | null => {
if (typeof body === "string") {
return body;
}
if (typeof body === "object" && body !== null && "message" in body) {
if (body && typeof body === "object" && "message" in body) {
const maybeMessage = (body as { message?: unknown }).message;
if (typeof maybeMessage === "string") {
return maybeMessage;
@ -132,15 +192,58 @@ async function defaultHandleError(response: Response) {
throw new ApiError(message, response, body);
}
// CSRF token management
const parseResponseBody = async (response: Response): Promise<unknown> => {
if (response.status === 204) {
return null;
}
const contentLength = response.headers.get("content-length");
if (contentLength === "0") {
return null;
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
try {
return await response.json();
} catch {
return null;
}
}
if (contentType.includes("text/")) {
try {
return await response.text();
} catch {
return null;
}
}
return null;
};
interface CsrfTokenPayload {
success: boolean;
token: string;
}
const isCsrfTokenPayload = (value: unknown): value is CsrfTokenPayload => {
return (
typeof value === "object" &&
value !== null &&
"success" in value &&
"token" in value &&
typeof (value as { success: unknown }).success === "boolean" &&
typeof (value as { token: unknown }).token === "string"
);
};
class CsrfTokenManager {
private token: string | null = null;
private tokenPromise: Promise<string> | null = null;
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
constructor(private readonly baseUrl: string) {}
async getToken(): Promise<string> {
if (this.token) {
@ -160,6 +263,11 @@ class CsrfTokenManager {
}
}
clearToken(): void {
this.token = null;
this.tokenPromise = null;
}
private async fetchToken(): Promise<string> {
const response = await fetch(`${this.baseUrl}/api/security/csrf/token`, {
method: "GET",
@ -173,118 +281,111 @@ class CsrfTokenManager {
throw new Error(`Failed to fetch CSRF token: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.token) {
const data: unknown = await response.json();
if (!isCsrfTokenPayload(data)) {
throw new Error("Invalid CSRF token response");
}
return data.token;
}
clearToken(): void {
this.token = null;
this.tokenPromise = null;
}
async refreshToken(): Promise<string> {
this.clearToken();
return this.getToken();
}
}
const SAFE_METHODS = new Set<HttpMethod>(["GET", "HEAD", "OPTIONS"]);
export function createClient(options: CreateClientOptions = {}): ApiClient {
const baseUrl = resolveBaseUrl(options.baseUrl);
const client = createOpenApiClient<paths>({ baseUrl });
const resolveAuthHeader = options.getAuthHeader;
const handleError = options.handleError ?? defaultHandleError;
const enableCsrf = options.enableCsrf ?? true;
const csrfManager = enableCsrf ? new CsrfTokenManager(baseUrl) : null;
if (typeof client.use === "function") {
const resolveAuthHeader = options.getAuthHeader;
const request = async <T>(
method: HttpMethod,
path: string,
opts: RequestOptions = {}
): Promise<ApiResponse<T>> => {
const resolvedPath = applyPathParams(path, opts.params?.path);
const url = new URL(resolvedPath, baseUrl);
const middleware: Middleware = {
async onRequest({ request }: MiddlewareCallbackParams) {
if (!request) return;
const queryString = buildQueryString(opts.params?.query);
if (queryString) {
url.search = queryString;
}
const nextRequest = new Request(request, {
credentials: "include",
});
const headers = new Headers(opts.headers);
// Add CSRF token for non-safe methods
if (csrfManager && ["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) {
try {
const csrfToken = await csrfManager.getToken();
nextRequest.headers.set("X-CSRF-Token", csrfToken);
} catch (error) {
console.warn("Failed to get CSRF token:", error);
// Continue without CSRF token - let the server handle the error
}
}
// Add auth header if available
if (resolveAuthHeader && typeof nextRequest.headers?.has === "function") {
if (!nextRequest.headers.has("Authorization")) {
const headerValue = resolveAuthHeader();
if (headerValue) {
nextRequest.headers.set("Authorization", headerValue);
}
}
}
return nextRequest;
},
async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) {
// Handle CSRF token refresh on 403 errors
if (response.status === 403 && csrfManager) {
try {
const errorText = await response.clone().text();
if (errorText.includes("CSRF") || errorText.includes("csrf")) {
// Clear the token so next request will fetch a new one
csrfManager.clearToken();
}
} catch {
// Ignore errors when checking response body
}
}
await handleError(response);
},
const credentials = opts.credentials ?? "include";
const init: RequestInit = {
method,
headers,
credentials,
signal: opts.signal,
};
client.use(middleware as never);
}
const body = opts.body;
if (body !== undefined && body !== null) {
if (body instanceof FormData || body instanceof Blob) {
init.body = body as BodyInit;
} else {
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
init.body = JSON.stringify(body);
}
}
const flexibleClient = client as ApiClient;
if (resolveAuthHeader && !headers.has("Authorization")) {
const headerValue = resolveAuthHeader();
if (headerValue) {
headers.set("Authorization", headerValue);
}
}
// Store references to original methods before overriding
const originalGET = client.GET.bind(client);
const originalPOST = client.POST.bind(client);
const originalPUT = client.PUT.bind(client);
const originalPATCH = client.PATCH.bind(client);
const originalDELETE = client.DELETE.bind(client);
if (
csrfManager &&
!opts.disableCsrf &&
!SAFE_METHODS.has(method) &&
!headers.has("X-CSRF-Token")
) {
try {
const csrfToken = await csrfManager.getToken();
headers.set("X-CSRF-Token", csrfToken);
} catch (error) {
console.warn("Failed to obtain CSRF token", error);
}
}
flexibleClient.GET = (async (path: string, options?: unknown) => {
return (originalGET as FlexibleApiMethods["GET"])(path, options);
}) as ApiClient["GET"];
const response = await fetch(url.toString(), init);
flexibleClient.POST = (async (path: string, options?: unknown) => {
return (originalPOST as FlexibleApiMethods["POST"])(path, options);
}) as ApiClient["POST"];
if (!response.ok) {
if (response.status === 403 && csrfManager) {
try {
const bodyText = await response.clone().text();
if (bodyText.toLowerCase().includes("csrf")) {
csrfManager.clearToken();
}
} catch {
csrfManager.clearToken();
}
}
flexibleClient.PUT = (async (path: string, options?: unknown) => {
return (originalPUT as FlexibleApiMethods["PUT"])(path, options);
}) as ApiClient["PUT"];
await handleError(response);
// If handleError does not throw, throw a default error to ensure rejection
throw new ApiError(`Request failed with status ${response.status}`, response);
}
flexibleClient.PATCH = (async (path: string, options?: unknown) => {
return (originalPATCH as FlexibleApiMethods["PATCH"])(path, options);
}) as ApiClient["PATCH"];
const parsedBody = await parseResponseBody(response);
flexibleClient.DELETE = (async (path: string, options?: unknown) => {
return (originalDELETE as FlexibleApiMethods["DELETE"])(path, options);
}) as ApiClient["DELETE"];
return {
data: (parsedBody as T | null | undefined) ?? null,
};
};
return flexibleClient;
return {
GET: (path, opts) => request("GET", path, opts),
POST: (path, opts) => request("POST", path, opts),
PUT: (path, opts) => request("PUT", path, opts),
PATCH: (path, opts) => request("PATCH", path, opts),
DELETE: (path, opts) => request("DELETE", path, opts),
} satisfies ApiClient;
}
export type { paths };

View File

@ -3,9 +3,6 @@
* Re-exports and additional types for the API client
*/
// Re-export generated types
export * from "./__generated__/types";
// Additional query parameter types
export interface InvoiceQueryParams {
page?: number;

View File

@ -45,7 +45,7 @@ src/
templates/ # Page layouts
features/ # Feature modules (auth, billing, etc.)
lib/ # Core utilities and services
api/ # OpenAPI client with type generation
api/ # Zod-aware fetch client + helpers
hooks/ # Shared React hooks
utils/ # Utility functions
providers/ # Context providers
@ -101,8 +101,8 @@ src/
## 🔗 **Integration Architecture**
### **API Client**
- **Implementation**: OpenAPI-based with `openapi-fetch`
- **Features**: Automatic type generation, CSRF protection, auth handling
- **Implementation**: Fetch wrapper using shared Zod schemas from `@customer-portal/domain`
- **Features**: CSRF protection, auth handling, consistent `ApiResponse` helpers
- **Location**: `apps/portal/src/lib/api/`
### **External Services**

View File

@ -54,7 +54,6 @@
"update:safe": "pnpm update --recursive && pnpm audit && pnpm type-check",
"dev:watch": "pnpm --parallel --filter @customer-portal/domain --filter @customer-portal/portal --filter @customer-portal/bff run dev",
"plesk:images": "bash ./scripts/plesk/build-images.sh",
"openapi:gen": "pnpm --filter @customer-portal/bff run openapi:gen",
"types:gen": "./scripts/generate-frontend-types.sh",
"codegen": "pnpm types:gen",
"postinstall": "pnpm codegen || true"

View File

@ -38,6 +38,12 @@ export const auditLogQuerySchema = paginationQuerySchema.extend({
export type AuditLogQuery = z.infer<typeof auditLogQuerySchema>;
export const dryRunQuerySchema = z.object({
dryRun: z.coerce.boolean().optional(),
});
export type DryRunQuery = z.infer<typeof dryRunQuerySchema>;
export const invoiceListQuerySchema = paginationQuerySchema.extend({
status: invoiceStatusEnum.optional(),
});
@ -299,6 +305,18 @@ export type Invoice = z.infer<typeof invoiceSchema>;
export type Pagination = z.infer<typeof paginationSchema>;
export type InvoiceList = z.infer<typeof invoiceListSchema>;
export const invoicePaymentLinkSchema = z.object({
paymentMethodId: z.coerce.number().int().positive().optional(),
gatewayName: z.string().min(1).optional(),
});
export type InvoicePaymentLinkInput = z.infer<typeof invoicePaymentLinkSchema>;
export const sfOrderIdParamSchema = z.object({
sfOrderId: z.string().min(1, "Salesforce order ID is required"),
});
export type SfOrderIdParam = z.infer<typeof sfOrderIdParamSchema>;
// =====================================================
// ID MAPPING SCHEMAS
// =====================================================

View File

@ -62,6 +62,8 @@ export {
invoiceListQuerySchema,
paginationQuerySchema,
subscriptionQuerySchema,
invoicePaymentLinkSchema,
sfOrderIdParamSchema,
// API types
type LoginRequestInput,
@ -88,6 +90,8 @@ export {
type InvoiceListQuery,
type PaginationQuery,
type SubscriptionQuery,
type InvoicePaymentLinkInput,
type SfOrderIdParam,
} from "./api/requests";
// Form schemas (frontend) - explicit exports for better tree shaking

View File

@ -1,16 +1,8 @@
#!/bin/bash
# 🎯 Automated Frontend Type Generation
# This script ensures frontend types are always in sync with backend OpenAPI spec
# Zod-based frontend type support
# OpenAPI generation has been removed; shared schemas live in @customer-portal/domain.
set -e
echo "🔄 Generating OpenAPI spec from backend..."
cd "$(dirname "$0")/.."
pnpm openapi:gen
echo "🔄 Generating frontend types from OpenAPI spec..."
npx openapi-typescript apps/bff/openapi/openapi.json -o apps/portal/src/lib/api/__generated__/types.ts
echo "✅ Frontend types updated successfully!"
echo "📝 Updated: apps/portal/src/lib/api/__generated__/types.ts"
echo " Skipping OpenAPI generation: frontend consumes shared Zod schemas from @customer-portal/domain."