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 - **jsforce** for Salesforce integration
- **WHMCS** API client - **WHMCS** API client
- **BullMQ** for async jobs with ioredis - **BullMQ** for async jobs with ioredis
- **OpenAPI/Swagger** for documentation - **Zod-first validation** shared via the domain package
### Temporarily Disabled Modules ### Temporarily Disabled Modules
@ -65,10 +65,10 @@ A modern customer portal where users can self-register, log in, browse & buy sub
new-portal-website/ new-portal-website/
├── apps/ ├── apps/
│ ├── portal/ # Next.js 15 frontend (React 19, Tailwind, shadcn/ui) │ ├── 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/ ├── packages/
│ ├── shared/ # Shared types and utilities │ ├── shared/ # Shared types and utilities
│ └── api-client/ # Generated OpenAPI client and types │ └── api-client/ # Lightweight fetch helpers + shared Zod types
├── scripts/ ├── scripts/
│ ├── dev/ # Development management scripts │ ├── dev/ # Development management scripts
│ └── prod/ # Production deployment scripts │ └── prod/ # Production deployment scripts
@ -128,7 +128,6 @@ new-portal-website/
4. **Access Your Applications** 4. **Access Your Applications**
- **Frontend**: http://localhost:3000 - **Frontend**: http://localhost:3000
- **Backend API**: http://localhost:4000/api - **Backend API**: http://localhost:4000/api
- **API Documentation**: http://localhost:4000/api/docs
### Development Commands ### Development Commands
@ -167,23 +166,20 @@ Upload the tar files in Plesk → Docker → Images → Upload, then deploy usin
### API Client ### API Client
The portal uses an integrated OpenAPI-based client with automatic type generation: The portal uses a lightweight fetch client that shares request/response contracts from
`@customer-portal/domain` and validates them with Zod:
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:
```ts ```ts
import { apiClient } from "@/lib/api"; import { apiClient, getDataOrThrow } from "@/lib/api";
// Client includes CSRF protection, auth headers, and error handling 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 ### Environment Configuration
- Local development: use the root `.env` (see `.env.example`). - 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:generate": "prisma generate",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:reset": "prisma migrate reset", "db:reset": "prisma migrate reset",
"db:seed": "NODE_OPTIONS=\"--max-old-space-size=4096\" tsx prisma/seed.ts", "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"
}, },
"dependencies": { "dependencies": {
"@customer-portal/domain": "workspace:*", "@customer-portal/domain": "workspace:*",
@ -40,7 +39,6 @@
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.6", "@nestjs/platform-express": "^11.1.6",
"@nestjs/swagger": "^11.2.0",
"@nestjs/throttler": "^6.4.0", "@nestjs/throttler": "^6.4.0",
"@prisma/client": "^6.14.0", "@prisma/client": "^6.14.0",
"@sendgrid/mail": "^8.1.6", "@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 { type INestApplication } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import helmet from "helmet"; import helmet from "helmet";
import cookieParser from "cookie-parser"; 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. // Rely on Nest's built-in shutdown hooks. External orchestrator will send signals.
app.enableShutdownHooks(); 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. // API routing prefix is applied via RouterModule in AppModule for clarity and modern routing.
const port = Number(configService.get("BFF_PORT", 4000)); const port = Number(configService.get("BFF_PORT", 4000));
@ -161,9 +144,5 @@ export async function bootstrap(): Promise<INestApplication> {
logger.log("🔴 Redis connection configured"); logger.log("🔴 Redis connection configured");
} }
if (configService.get("NODE_ENV") !== "production") {
logger.log(`📚 API Documentation: http://localhost:${port}/docs`);
}
return app; return app;
} }

View File

@ -12,17 +12,17 @@ export interface DevAuthConfig {
} }
export const createDevAuthConfig = (): DevAuthConfig => { export const createDevAuthConfig = (): DevAuthConfig => {
const isDevelopment = process.env.NODE_ENV !== 'production'; const isDevelopment = process.env.NODE_ENV !== "production";
return { return {
// Disable CSRF protection in development for easier testing // 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 // 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 // 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 // Enable debug logs in development
enableDebugLogs: isDevelopment, enableDebugLogs: isDevelopment,

View File

@ -1,9 +1,7 @@
import { Controller, Get } from "@nestjs/common"; 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 { WhmcsRequestQueueService } from "@bff/core/queue/services/whmcs-request-queue.service";
import { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service"; import { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service";
@ApiTags("Health")
@Controller("health/queues") @Controller("health/queues")
export class QueueHealthController { export class QueueHealthController {
constructor( constructor(
@ -12,14 +10,6 @@ export class QueueHealthController {
) {} ) {}
@Get() @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() { getQueueHealth() {
return { return {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@ -36,14 +26,6 @@ export class QueueHealthController {
} }
@Get("whmcs") @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() { getWhmcsQueueMetrics() {
return { return {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@ -53,15 +35,6 @@ export class QueueHealthController {
} }
@Get("salesforce") @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() { getSalesforceQueueMetrics() {
return { return {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),

View File

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

View File

@ -1,15 +1,14 @@
import { Controller, Get, Post, Req, Res, Inject } from "@nestjs/common"; 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 type { Request, Response } from "express";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { CsrfService } from "../services/csrf.service"; import { CsrfService } from "../services/csrf.service";
interface AuthenticatedRequest extends Request { type AuthenticatedRequest = Request & {
user?: { id: string; sessionId?: string }; user?: { id: string; sessionId?: string };
sessionID?: string; sessionID?: string;
} cookies: Record<string, string | undefined>;
};
@ApiTags("Security")
@Controller("security/csrf") @Controller("security/csrf")
export class CsrfController { export class CsrfController {
constructor( constructor(
@ -18,22 +17,6 @@ export class CsrfController {
) {} ) {}
@Get("token") @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) { getCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined; const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined;
const userId = req.user?.id; const userId = req.user?.id;
@ -65,23 +48,6 @@ export class CsrfController {
} }
@Post("refresh") @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) { refreshCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined; const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined;
const userId = req.user?.id || "anonymous"; // Default for unauthenticated users const userId = req.user?.id || "anonymous"; // Default for unauthenticated users
@ -116,31 +82,6 @@ export class CsrfController {
} }
@Get("stats") @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) { getCsrfStats(@Req() req: AuthenticatedRequest) {
const userId = req.user?.id || "anonymous"; const userId = req.user?.id || "anonymous";
@ -160,6 +101,8 @@ export class CsrfController {
} }
private extractSessionId(req: AuthenticatedRequest): string | null { 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 { CsrfService } from "../services/csrf.service";
import { devAuthConfig } from "../../config/auth-dev.config"; 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; csrfToken?: string;
user?: { id: string; sessionId?: string }; user?: { id: string; sessionId?: string };
sessionID?: string; sessionID?: string;
} cookies: CookieJar;
};
/** /**
* CSRF Protection Middleware * CSRF Protection Middleware
@ -159,7 +179,11 @@ export class CsrfMiddleware implements NestMiddleware {
const sessionId = req.user?.sessionId || this.extractSessionId(req); const sessionId = req.user?.sessionId || this.extractSessionId(req);
const userId = req.user?.id; 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); this.setCsrfSecretCookie(res, tokenData.secret, tokenData.expiresAt);
@ -193,15 +217,19 @@ export class CsrfMiddleware implements NestMiddleware {
} }
// 4. Request body (for form submissions) // 4. Request body (for form submissions)
if (req.body && typeof req.body === "object") { const bodyToken =
token = req.body._csrf || req.body.csrfToken; this.normalizeTokenValue(req.body._csrf) ?? this.normalizeTokenValue(req.body.csrfToken);
if (token) return token; if (bodyToken) {
return bodyToken;
} }
// 5. Query parameter (least secure, only for GET requests) // 5. Query parameter (least secure, only for GET requests)
if (req.method === "GET") { if (req.method === "GET") {
token = (req.query._csrf as string) || (req.query.csrfToken as string); const queryToken =
if (token) return token; this.normalizeTokenValue(req.query._csrf) ?? this.normalizeTokenValue(req.query.csrfToken);
if (queryToken) {
return queryToken;
}
} }
return null; return null;
@ -209,12 +237,24 @@ export class CsrfMiddleware implements NestMiddleware {
private extractSecretFromCookie(req: CsrfRequest): string | null { private extractSecretFromCookie(req: CsrfRequest): string | null {
const cookieName = this.csrfService.getCookieName(); 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 { private extractSessionId(req: CsrfRequest): string | null {
// Try to extract session ID from various sources // 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 { 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); 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; 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. * 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"); this.logger.debug("invalidateUserTokens called - rotate cookie to enforce");
} }
getTokenStats() { getTokenStats(): CsrfTokenStats {
return { return {
mode: "stateless", mode: "stateless",
totalTokens: 0, totalTokens: 0,
activeTokens: 0, activeTokens: 0,
expiredTokens: 0, expiredTokens: 0,
cacheSize: 0,
maxCacheSize: 0,
}; };
} }

View File

@ -418,8 +418,8 @@ export class SecureErrorMapperService {
/database|sql|connection/i, /database|sql|connection/i,
/file|path|directory/i, /file|path|directory/i,
/\s+at\s+.*\.js:\d+/i, // Stack traces /\s+at\s+.*\.js:\d+/i, // Stack traces
/[a-zA-Z]:[\\\/]/, // Windows paths /[a-zA-Z]:[\\/]/, // Windows paths
/[/][a-zA-Z0-9._\-/]+\.(js|ts|py|php)/i, // Unix paths /\/[a-zA-Z0-9._\-/]+\.(js|ts|py|php)/i, // Unix paths
/\b(?:\d{1,3}\.){3}\d{1,3}\b/, // IP addresses /\b(?:\d{1,3}\.){3}\d{1,3}\b/, // IP addresses
/[A-Za-z0-9]{32,}/, // Long tokens/hashes /[A-Za-z0-9]{32,}/, // Long tokens/hashes
]; ];
@ -461,7 +461,7 @@ export class SecureErrorMapperService {
// Remove stack traces // Remove stack traces
.replace(/\s+at\s+.*/g, "") .replace(/\s+at\s+.*/g, "")
// Remove absolute paths // Remove absolute paths
.replace(/[a-zA-Z]:[\\\/][^:]+/g, "[path]") .replace(/[a-zA-Z]:[\\/][^:]+/g, "[path]")
// Remove IP addresses // Remove IP addresses
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]") .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]")
// Remove URLs with credentials // 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 type { Request, Response } from "express";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ZodValidationException } from "nestjs-zod"; import { ZodValidationException } from "nestjs-zod";
import type { ZodError, ZodIssue } from "zod";
interface ZodIssueResponse { interface ZodIssueResponse {
path: string; path: string;
@ -18,12 +19,18 @@ export class ZodValidationExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<Request>();
const zodError = exception.getZodError(); const rawZodError = exception.getZodError();
const issues: ZodIssueResponse[] = zodError.issues.map(issue => ({ let issues: ZodIssueResponse[] = [];
path: issue.path.join(".") || "root",
message: issue.message, if (!this.isZodError(rawZodError)) {
code: issue.code, 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", { this.logger.warn("Request validation failed", {
path: request.url, path: request.url,
@ -42,4 +49,18 @@ export class ZodValidationExceptionFilter implements ExceptionFilter {
path: request.url, 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, WhmcsCapturePaymentParams,
} from "../types/whmcs-api.types"; } from "../types/whmcs-api.types";
export interface InvoiceFilters { export type InvoiceFilters = Partial<{
status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; status: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
page?: number; page: number;
limit?: number; limit: number;
} }>;
@Injectable() @Injectable()
export class WhmcsInvoiceService { export class WhmcsInvoiceService {
@ -62,26 +62,15 @@ export class WhmcsInvoiceService {
const response = await this.connectionService.getInvoices(params); const response = await this.connectionService.getInvoices(params);
const transformed = this.transformInvoicesResponse(response, clientId, page, limit); const transformed = this.transformInvoicesResponse(response, clientId, page, limit);
const parseResult = invoiceListSchema.safeParse(transformed); const result = invoiceListSchema.parse(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;
// Cache the result // 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( this.logger.log(
`Fetched ${result.invoices.length} invoices for client ${clientId}, page ${page}` `Fetched ${result.invoices.length} invoices for client ${clientId}, page ${page}`
); );
return result as any; return result;
} catch (error) { } catch (error) {
this.logger.error(`Failed to fetch invoices for client ${clientId}`, { this.logger.error(`Failed to fetch invoices for client ${clientId}`, {
error: getErrorMessage(error), error: getErrorMessage(error),
@ -110,16 +99,7 @@ export class WhmcsInvoiceService {
try { try {
// Get detailed invoice with items // Get detailed invoice with items
const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id); const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id);
const parseResult = invoiceSchema.safeParse(detailedInvoice); return invoiceSchema.parse(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;
} catch (error) { } catch (error) {
this.logger.warn( this.logger.warn(
`Failed to fetch details for invoice ${invoice.id}`, `Failed to fetch details for invoice ${invoice.id}`,
@ -132,7 +112,7 @@ export class WhmcsInvoiceService {
); );
const result: InvoiceList = { const result: InvoiceList = {
invoices: invoicesWithItems as any, invoices: invoicesWithItems,
pagination: invoiceList.pagination, pagination: invoiceList.pagination,
}; };

View File

@ -6,15 +6,21 @@ import {
UseGuards, UseGuards,
Query, Query,
BadRequestException, BadRequestException,
UsePipes,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
import { AdminGuard } from "./guards/admin.guard"; import { AdminGuard } from "./guards/admin.guard";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
import { UsersService } from "@bff/modules/users/users.service"; import { UsersService } from "@bff/modules/users/users.service";
import { TokenMigrationService } from "@bff/modules/auth/infra/token/token-migration.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) @UseGuards(AdminGuard)
@Controller("auth/admin") @Controller("auth/admin")
export class AuthAdminController { export class AuthAdminController {
@ -25,41 +31,27 @@ export class AuthAdminController {
) {} ) {}
@Get("audit-logs") @Get("audit-logs")
@ApiOperation({ summary: "Get audit logs (admin only)" }) @UsePipes(new ZodValidationPipe(auditLogQuerySchema))
@ApiResponse({ status: 200, description: "Audit logs retrieved" }) async getAuditLogs(@Query() query: AuditLogQuery) {
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");
}
const { logs, total } = await this.auditService.getAuditLogs({ const { logs, total } = await this.auditService.getAuditLogs({
page: pageNum, page: query.page,
limit: limitNum, limit: query.limit,
action, action: query.action as AuditAction | undefined,
userId, userId: query.userId,
}); });
return { return {
logs, logs,
pagination: { pagination: {
page: pageNum, page: query.page,
limit: limitNum, limit: query.limit,
total, total,
totalPages: Math.ceil(total / limitNum), totalPages: Math.ceil(total / query.limit),
}, },
}; };
} }
@Post("unlock-account/:userId") @Post("unlock-account/:userId")
@ApiOperation({ summary: "Unlock user account (admin only)" })
@ApiResponse({ status: 200, description: "Account unlocked" })
async unlockAccount(@Param("userId") userId: string) { async unlockAccount(@Param("userId") userId: string) {
const user = await this.usersService.findById(userId); const user = await this.usersService.findById(userId);
if (!user) { if (!user) {
@ -83,24 +75,19 @@ export class AuthAdminController {
} }
@Get("security-stats") @Get("security-stats")
@ApiOperation({ summary: "Get security statistics (admin only)" })
@ApiResponse({ status: 200, description: "Security stats retrieved" })
async getSecurityStats() { async getSecurityStats() {
return this.auditService.getSecurityStats(); return this.auditService.getSecurityStats();
} }
@Get("token-migration/status") @Get("token-migration/status")
@ApiOperation({ summary: "Get token migration status (admin only)" })
@ApiResponse({ status: 200, description: "Migration status retrieved" })
async getTokenMigrationStatus() { async getTokenMigrationStatus() {
return this.tokenMigrationService.getMigrationStatus(); return this.tokenMigrationService.getMigrationStatus();
} }
@Post("token-migration/run") @Post("token-migration/run")
@ApiOperation({ summary: "Run token migration (admin only)" }) @UsePipes(new ZodValidationPipe(dryRunQuerySchema))
@ApiResponse({ status: 200, description: "Migration completed" }) async runTokenMigration(@Query() query: DryRunQuery) {
async runTokenMigration(@Query("dryRun") dryRun: string = "true") { const isDryRun = query.dryRun ?? true;
const isDryRun = dryRun.toLowerCase() !== "false";
const stats = await this.tokenMigrationService.migrateExistingTokens(isDryRun); const stats = await this.tokenMigrationService.migrateExistingTokens(isDryRun);
await this.auditService.log({ await this.auditService.log({
@ -121,10 +108,9 @@ export class AuthAdminController {
} }
@Post("token-migration/cleanup") @Post("token-migration/cleanup")
@ApiOperation({ summary: "Clean up orphaned tokens (admin only)" }) @UsePipes(new ZodValidationPipe(dryRunQuerySchema))
@ApiResponse({ status: 200, description: "Cleanup completed" }) async cleanupOrphanedTokens(@Query() query: DryRunQuery) {
async cleanupOrphanedTokens(@Query("dryRun") dryRun: string = "true") { const isDryRun = query.dryRun ?? true;
const isDryRun = dryRun.toLowerCase() !== "false";
const stats = await this.tokenMigrationService.cleanupOrphanedTokens(isDryRun); const stats = await this.tokenMigrationService.cleanupOrphanedTokens(isDryRun);
await this.auditService.log({ 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 { AuthThrottleGuard } from "./guards/auth-throttle.guard";
import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard"; import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from "@nestjs/swagger";
import { Public } from "./decorators/public.decorator"; import { Public } from "./decorators/public.decorator";
import { ZodValidationPipe } from "@bff/core/validation"; import { ZodValidationPipe } from "@bff/core/validation";
@ -78,7 +77,6 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => {
return Math.max(0, expiresAt - Date.now()); return Math.max(0, expiresAt - Date.now());
}; };
@ApiTags("auth")
@Controller("auth") @Controller("auth")
export class AuthController { export class AuthController {
constructor(private authFacade: AuthFacade) {} constructor(private authFacade: AuthFacade) {}
@ -107,19 +105,12 @@ export class AuthController {
@UseGuards(AuthThrottleGuard) @UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 20, ttl: 600000 } }) // 20 validations per 10 minutes per IP @Throttle({ default: { limit: 20, ttl: 600000 } }) // 20 validations per 10 minutes per IP
@UsePipes(new ZodValidationPipe(validateSignupRequestSchema)) @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) { async validateSignup(@Body() validateData: ValidateSignupRequestInput, @Req() req: Request) {
return this.authFacade.validateSignup(validateData, req); return this.authFacade.validateSignup(validateData, req);
} }
@Public() @Public()
@Get("health-check") @Get("health-check")
@ApiOperation({ summary: "Check auth service health and integrations" })
@ApiResponse({ status: 200, description: "Health check results" })
async healthCheck() { async healthCheck() {
return this.authFacade.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 @Throttle({ default: { limit: 20, ttl: 600000 } }) // 20 validations per 10 minutes per IP
@UsePipes(new ZodValidationPipe(signupRequestSchema)) @UsePipes(new ZodValidationPipe(signupRequestSchema))
@HttpCode(200) @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) { async signupPreflight(@Body() signupData: SignupRequestInput) {
return this.authFacade.signupPreflight(signupData); return this.authFacade.signupPreflight(signupData);
} }
@ -139,8 +128,6 @@ export class AuthController {
@Public() @Public()
@Post("account-status") @Post("account-status")
@UsePipes(new ZodValidationPipe(accountStatusRequestSchema)) @UsePipes(new ZodValidationPipe(accountStatusRequestSchema))
@ApiOperation({ summary: "Get account status by email" })
@ApiOkResponse({ description: "Account status" })
async accountStatus(@Body() body: AccountStatusRequestInput) { async accountStatus(@Body() body: AccountStatusRequestInput) {
return this.authFacade.getAccountStatus(body.email); return this.authFacade.getAccountStatus(body.email);
} }
@ -150,10 +137,6 @@ export class AuthController {
@UseGuards(AuthThrottleGuard) @UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 signups per 15 minutes per IP (reasonable for account creation) @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 signups per 15 minutes per IP (reasonable for account creation)
@UsePipes(new ZodValidationPipe(signupRequestSchema)) @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( async signup(
@Body() signupData: SignupRequestInput, @Body() signupData: SignupRequestInput,
@Req() req: Request, @Req() req: Request,
@ -168,10 +151,6 @@ export class AuthController {
@UseGuards(LocalAuthGuard, FailedLoginThrottleGuard) @UseGuards(LocalAuthGuard, FailedLoginThrottleGuard)
@UseInterceptors(LoginResultInterceptor) @UseInterceptors(LoginResultInterceptor)
@Post("login") @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( async login(
@Req() req: Request & { user: { id: string; email: string; role: string } }, @Req() req: Request & { user: { id: string; email: string; role: string } },
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
@ -182,8 +161,6 @@ export class AuthController {
} }
@Post("logout") @Post("logout")
@ApiOperation({ summary: "Logout user" })
@ApiResponse({ status: 200, description: "Logout successful" })
async logout( async logout(
@Req() req: RequestWithCookies & { user: { id: string } }, @Req() req: RequestWithCookies & { user: { id: string } },
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
@ -198,10 +175,6 @@ export class AuthController {
@Post("refresh") @Post("refresh")
@Throttle({ default: { limit: 10, ttl: 300000 } }) // 10 attempts per 5 minutes per IP @Throttle({ default: { limit: 10, ttl: 300000 } }) // 10 attempts per 5 minutes per IP
@UsePipes(new ZodValidationPipe(refreshTokenRequestSchema)) @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( async refreshToken(
@Body() body: RefreshTokenRequestInput, @Body() body: RefreshTokenRequestInput,
@Req() req: RequestWithCookies, @Req() req: RequestWithCookies,
@ -221,13 +194,6 @@ export class AuthController {
@UseGuards(AuthThrottleGuard) @UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 5, ttl: 600000 } }) // 5 attempts per 10 minutes per IP (industry standard) @Throttle({ default: { limit: 5, ttl: 600000 } }) // 5 attempts per 10 minutes per IP (industry standard)
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) @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) { async linkWhmcs(@Body() linkData: LinkWhmcsRequestInput, @Req() _req: Request) {
return this.authFacade.linkWhmcsUser(linkData); return this.authFacade.linkWhmcsUser(linkData);
} }
@ -237,10 +203,6 @@ export class AuthController {
@UseGuards(AuthThrottleGuard) @UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 5, ttl: 600000 } }) // 5 attempts per 10 minutes per IP+UA (industry standard) @Throttle({ default: { limit: 5, ttl: 600000 } }) // 5 attempts per 10 minutes per IP+UA (industry standard)
@UsePipes(new ZodValidationPipe(setPasswordRequestSchema)) @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( async setPassword(
@Body() setPasswordData: SetPasswordRequestInput, @Body() setPasswordData: SetPasswordRequestInput,
@Req() _req: Request, @Req() _req: Request,
@ -255,8 +217,6 @@ export class AuthController {
@Post("check-password-needed") @Post("check-password-needed")
@UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema)) @UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema))
@HttpCode(200) @HttpCode(200)
@ApiOperation({ summary: "Check if user needs to set password" })
@ApiResponse({ status: 200, description: "Password status checked" })
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestInput) { async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestInput) {
return this.authFacade.checkPasswordNeeded(data.email); return this.authFacade.checkPasswordNeeded(data.email);
} }
@ -265,8 +225,6 @@ export class AuthController {
@Post("request-password-reset") @Post("request-password-reset")
@Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 attempts per 15 minutes (standard for password operations) @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 attempts per 15 minutes (standard for password operations)
@UsePipes(new ZodValidationPipe(passwordResetRequestSchema)) @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) { async requestPasswordReset(@Body() body: PasswordResetRequestInput) {
await this.authFacade.requestPasswordReset(body.email, req); await this.authFacade.requestPasswordReset(body.email, req);
return { message: "If an account exists, a reset email has been sent" }; return { message: "If an account exists, a reset email has been sent" };
@ -276,8 +234,6 @@ export class AuthController {
@Post("reset-password") @Post("reset-password")
@Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 attempts per 15 minutes (standard for password operations) @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 attempts per 15 minutes (standard for password operations)
@UsePipes(new ZodValidationPipe(passwordResetSchema)) @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) { async resetPassword(@Body() body: PasswordResetInput, @Res({ passthrough: true }) res: Response) {
const result = await this.authFacade.resetPassword(body.token, body.password); const result = await this.authFacade.resetPassword(body.token, body.password);
this.setAuthCookies(res, result.tokens); this.setAuthCookies(res, result.tokens);
@ -287,8 +243,6 @@ export class AuthController {
@Post("change-password") @Post("change-password")
@Throttle({ default: { limit: 5, ttl: 300000 } }) @Throttle({ default: { limit: 5, ttl: 300000 } })
@UsePipes(new ZodValidationPipe(changePasswordRequestSchema)) @UsePipes(new ZodValidationPipe(changePasswordRequestSchema))
@ApiOperation({ summary: "Change password (authenticated)" })
@ApiResponse({ status: 200, description: "Password changed successfully" })
async changePassword( async changePassword(
@Req() req: Request & { user: { id: string } }, @Req() req: Request & { user: { id: string } },
@Body() body: ChangePasswordRequestInput, @Body() body: ChangePasswordRequestInput,
@ -305,7 +259,6 @@ export class AuthController {
} }
@Get("me") @Get("me")
@ApiOperation({ summary: "Get current authentication status" })
getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role: string } }) { getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role: string } }) {
// Return basic auth info only - full profile should use /api/me // Return basic auth info only - full profile should use /api/me
return { return {
@ -320,12 +273,6 @@ export class AuthController {
@Post("sso-link") @Post("sso-link")
@UsePipes(new ZodValidationPipe(ssoLinkRequestSchema)) @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( async createSsoLink(
@Req() req: Request & { user: { id: string } }, @Req() req: Request & { user: { id: string } },
@Body() body: SsoLinkRequestInput @Body() body: SsoLinkRequestInput

View File

@ -1,5 +1,4 @@
import { Controller, Get, Request } from "@nestjs/common"; import { Controller, Get, Request } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import type { import type {
InternetAddonCatalogItem, InternetAddonCatalogItem,
InternetInstallationCatalogItem, InternetInstallationCatalogItem,
@ -12,9 +11,7 @@ import { InternetCatalogService } from "./services/internet-catalog.service";
import { SimCatalogService } from "./services/sim-catalog.service"; import { SimCatalogService } from "./services/sim-catalog.service";
import { VpnCatalogService } from "./services/vpn-catalog.service"; import { VpnCatalogService } from "./services/vpn-catalog.service";
@ApiTags("catalog")
@Controller("catalog") @Controller("catalog")
@ApiBearerAuth()
export class CatalogController { export class CatalogController {
constructor( constructor(
private internetCatalog: InternetCatalogService, private internetCatalog: InternetCatalogService,
@ -23,10 +20,7 @@ export class CatalogController {
) {} ) {}
@Get("internet/plans") @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[]; plans: InternetPlanCatalogItem[];
installations: InternetInstallationCatalogItem[]; installations: InternetInstallationCatalogItem[];
addons: InternetAddonCatalogItem[]; addons: InternetAddonCatalogItem[];
@ -48,19 +42,16 @@ export class CatalogController {
} }
@Get("internet/addons") @Get("internet/addons")
@ApiOperation({ summary: "Get Internet add-ons" })
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> { async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
return this.internetCatalog.getAddons(); return this.internetCatalog.getAddons();
} }
@Get("internet/installations") @Get("internet/installations")
@ApiOperation({ summary: "Get Internet installations" })
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> { async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
return this.internetCatalog.getInstallations(); return this.internetCatalog.getInstallations();
} }
@Get("sim/plans") @Get("sim/plans")
@ApiOperation({ summary: "Get SIM plans filtered by user's existing services" })
async getSimPlans(@Request() req: { user: { id: string } }): Promise<SimCatalogProduct[]> { async getSimPlans(@Request() req: { user: { id: string } }): Promise<SimCatalogProduct[]> {
const userId = req.user?.id; const userId = req.user?.id;
if (!userId) { if (!userId) {
@ -72,25 +63,21 @@ export class CatalogController {
} }
@Get("sim/activation-fees") @Get("sim/activation-fees")
@ApiOperation({ summary: "Get SIM activation fees" })
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> { async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
return this.simCatalog.getActivationFees(); return this.simCatalog.getActivationFees();
} }
@Get("sim/addons") @Get("sim/addons")
@ApiOperation({ summary: "Get SIM add-ons" })
async getSimAddons(): Promise<SimCatalogProduct[]> { async getSimAddons(): Promise<SimCatalogProduct[]> {
return this.simCatalog.getAddons(); return this.simCatalog.getAddons();
} }
@Get("vpn/plans") @Get("vpn/plans")
@ApiOperation({ summary: "Get VPN plans" })
async getVpnPlans(): Promise<VpnCatalogProduct[]> { async getVpnPlans(): Promise<VpnCatalogProduct[]> {
return this.vpnCatalog.getPlans(); return this.vpnCatalog.getPlans();
} }
@Get("vpn/activation-fees") @Get("vpn/activation-fees")
@ApiOperation({ summary: "Get VPN activation fees" })
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> { async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
return this.vpnCatalog.getActivationFees(); return this.vpnCatalog.getActivationFees();
} }

View File

@ -11,16 +11,6 @@ import {
BadRequestException, BadRequestException,
UsePipes, UsePipes,
} from "@nestjs/common"; } 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 { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
@ -36,23 +26,13 @@ import type {
InvoicePaymentLink, InvoicePaymentLink,
InvoiceListQuery, InvoiceListQuery,
} from "@customer-portal/domain"; } from "@customer-portal/domain";
import { import { invoiceListQuerySchema } from "@customer-portal/domain";
invoiceSchema,
invoiceListSchema,
invoiceListQuerySchema,
} from "@customer-portal/domain";
// ✅ CLEAN: DTOs only for OpenAPI generation
class InvoiceDto extends createZodDto(invoiceSchema) {}
class InvoiceListDto extends createZodDto(invoiceListSchema) {}
interface AuthenticatedRequest { interface AuthenticatedRequest {
user: { id: string }; user: { id: string };
} }
@ApiTags("invoices")
@Controller("invoices") @Controller("invoices")
@ApiBearerAuth()
export class InvoicesController { export class InvoicesController {
constructor( constructor(
private readonly invoicesService: InvoicesOrchestratorService, private readonly invoicesService: InvoicesOrchestratorService,
@ -61,33 +41,6 @@ export class InvoicesController {
) {} ) {}
@Get() @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)) @UsePipes(new ZodValidationPipe(invoiceListQuerySchema))
async getInvoices( async getInvoices(
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
@ -97,11 +50,6 @@ export class InvoicesController {
} }
@Get("payment-methods") @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> { async getPaymentMethods(@Request() req: AuthenticatedRequest): Promise<PaymentMethodList> {
const mapping = await this.mappingsService.findByUserId(req.user.id); const mapping = await this.mappingsService.findByUserId(req.user.id);
if (!mapping?.whmcsClientId) { if (!mapping?.whmcsClientId) {
@ -111,22 +59,12 @@ export class InvoicesController {
} }
@Get("payment-gateways") @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> { async getPaymentGateways(): Promise<PaymentGatewayList> {
return this.whmcsService.getPaymentGateways(); return this.whmcsService.getPaymentGateways();
} }
@Post("payment-methods/refresh") @Post("payment-methods/refresh")
@HttpCode(HttpStatus.OK) @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> { async refreshPaymentMethods(@Request() req: AuthenticatedRequest): Promise<PaymentMethodList> {
// Invalidate cache first // Invalidate cache first
await this.whmcsService.invalidatePaymentMethodsCache(req.user.id); await this.whmcsService.invalidatePaymentMethodsCache(req.user.id);
@ -140,16 +78,6 @@ export class InvoicesController {
} }
@Get(":id") @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( async getInvoiceById(
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
@Param("id", ParseIntPipe) invoiceId: number @Param("id", ParseIntPipe) invoiceId: number
@ -162,13 +90,6 @@ export class InvoicesController {
} }
@Get(":id/subscriptions") @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( getInvoiceSubscriptions(
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
@Param("id", ParseIntPipe) invoiceId: number @Param("id", ParseIntPipe) invoiceId: number
@ -184,19 +105,6 @@ export class InvoicesController {
@Post(":id/sso-link") @Post(":id/sso-link")
@HttpCode(HttpStatus.OK) @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( async createSsoLink(
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
@Param("id", ParseIntPipe) invoiceId: number, @Param("id", ParseIntPipe) invoiceId: number,
@ -230,26 +138,6 @@ export class InvoicesController {
@Post(":id/payment-link") @Post(":id/payment-link")
@HttpCode(HttpStatus.OK) @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( async createPaymentLink(
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
@Param("id", ParseIntPipe) invoiceId: number, @Param("id", ParseIntPipe) invoiceId: number,

View File

@ -1,12 +1,15 @@
import { Body, Controller, Get, Param, Post, Request, UsePipes } from "@nestjs/common"; import { Body, Controller, Get, Param, Post, Request, UsePipes } from "@nestjs/common";
import { OrderOrchestrator } from "./services/order-orchestrator.service"; 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 type { RequestWithUser } from "@bff/modules/auth/auth.types";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ZodValidationPipe } from "@bff/core/validation"; 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") @Controller("orders")
export class OrdersController { export class OrdersController {
constructor( constructor(
@ -14,12 +17,8 @@ export class OrdersController {
private readonly logger: Logger private readonly logger: Logger
) {} ) {}
@ApiBearerAuth()
@Post() @Post()
@UsePipes(new ZodValidationPipe(createOrderRequestSchema)) @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) { async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequest) {
this.logger.log( this.logger.log(
{ {
@ -45,19 +44,15 @@ export class OrdersController {
} }
} }
@ApiBearerAuth()
@Get("user") @Get("user")
@ApiOperation({ summary: "Get user's orders" })
async getUserOrders(@Request() req: RequestWithUser) { async getUserOrders(@Request() req: RequestWithUser) {
return this.orderOrchestrator.getOrdersForUser(req.user.id); return this.orderOrchestrator.getOrdersForUser(req.user.id);
} }
@ApiBearerAuth()
@Get(":sfOrderId") @Get(":sfOrderId")
@ApiOperation({ summary: "Get order summary/status" }) @UsePipes(new ZodValidationPipe(sfOrderIdParamSchema))
@ApiParam({ name: "sfOrderId", type: String }) async get(@Request() req: RequestWithUser, @Param() params: SfOrderIdParam) {
async get(@Request() req: RequestWithUser, @Param("sfOrderId") sfOrderId: string) { return this.orderOrchestrator.getOrder(params.sfOrderId);
return this.orderOrchestrator.getOrder(sfOrderId);
} }
// Note: Order provisioning has been moved to SalesforceProvisioningController // 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 { getErrorMessage } from "@bff/core/utils/error.util";
import type { SalesforceOrderRecord } from "@customer-portal/domain"; import type { SalesforceOrderRecord } from "@customer-portal/domain";
import { SalesforceFieldMapService, type SalesforceFieldMap } from "@bff/core/config/field-map"; 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"; type OrderStringFieldKey = "activationStatus";
export interface OrderFulfillmentValidationResult { export interface OrderFulfillmentValidationResult {

View File

@ -2,6 +2,9 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nes
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { OrderPricebookService } from "./order-pricebook.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 * Handles building order items from SKU data
@ -11,7 +14,8 @@ export class OrderItemBuilder {
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly sf: SalesforceConnection, 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[], skus: string[],
pricebookId: string pricebookId: string
): Promise<void> { ): Promise<void> {
if (skus.length === 0) { const { skus: validatedSkus } = buildItemsSchema.parse({ skus });
throw new BadRequestException("No products specified for order"); if (pricebookId.length === 0) {
throw new BadRequestException("Product SKU is required");
} }
this.logger.log({ orderId, skus }, "Creating OrderItems from SKU array"); 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); const metaMap = await this.pricebookService.fetchProductMeta(pricebookId, skus);
// Create OrderItems for each SKU // Create OrderItems for each SKU
for (const sku of skus) { for (const sku of validatedSkus) {
const normalizedSkuValue = sku?.trim(); const normalizedSkuValue = sku?.trim();
if (!normalizedSkuValue) { if (!normalizedSkuValue) {
this.logger.error({ orderId }, "Encountered empty SKU while creating order items"); 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 { 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 type { RequestWithUser } from "@bff/modules/auth/auth.types";
import { SimOrderActivationService } from "./sim-order-activation.service"; import { SimOrderActivationService } from "./sim-order-activation.service";
import type { SimOrderActivationRequest } from "./sim-order-activation.service"; import type { SimOrderActivationRequest } from "./sim-order-activation.service";
@ApiTags("sim-orders")
@ApiBearerAuth()
@Controller("subscriptions/sim/orders") @Controller("subscriptions/sim/orders")
export class SimOrdersController { export class SimOrdersController {
constructor(private readonly activation: SimOrderActivationService) {} constructor(private readonly activation: SimOrderActivationService) {}
@Post("activate") @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) { async activate(@Request() req: RequestWithUser, @Body() body: SimOrderActivationRequest) {
const result = await this.activation.activate(req.user.id, body); const result = await this.activation.activate(req.user.id, body);
return result; return result;

View File

@ -10,16 +10,6 @@ import {
BadRequestException, BadRequestException,
UsePipes, UsePipes,
} from "@nestjs/common"; } from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiQuery,
ApiBearerAuth,
ApiParam,
ApiBody,
} from "@nestjs/swagger";
import { SubscriptionsService } from "./subscriptions.service"; import { SubscriptionsService } from "./subscriptions.service";
import { SimManagementService } from "./sim-management.service"; import { SimManagementService } from "./sim-management.service";
@ -43,9 +33,7 @@ import {
import { ZodValidationPipe } from "@bff/core/validation"; import { ZodValidationPipe } from "@bff/core/validation";
import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import type { RequestWithUser } from "@bff/modules/auth/auth.types";
@ApiTags("subscriptions")
@Controller("subscriptions") @Controller("subscriptions")
@ApiBearerAuth()
export class SubscriptionsController { export class SubscriptionsController {
constructor( constructor(
private readonly subscriptionsService: SubscriptionsService, private readonly subscriptionsService: SubscriptionsService,
@ -53,17 +41,6 @@ export class SubscriptionsController {
) {} ) {}
@Get() @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)) @UsePipes(new ZodValidationPipe(subscriptionQuerySchema))
async getSubscriptions( async getSubscriptions(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@ -77,21 +54,11 @@ export class SubscriptionsController {
} }
@Get("active") @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[]> { async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
return this.subscriptionsService.getActiveSubscriptions(req.user.id); return this.subscriptionsService.getActiveSubscriptions(req.user.id);
} }
@Get("stats") @Get("stats")
@ApiOperation({
summary: "Get subscription statistics",
description: "Retrieves subscription count statistics by status",
})
@ApiOkResponse({ description: "Subscription statistics" })
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{ async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{
total: number; total: number;
active: number; active: number;
@ -103,13 +70,6 @@ export class SubscriptionsController {
} }
@Get(":id") @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( async getSubscriptionById(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
@ -122,25 +82,6 @@ export class SubscriptionsController {
} }
@Get(":id/invoices") @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)) @UsePipes(new ZodValidationPipe(invoiceListQuerySchema))
async getSubscriptionInvoices( async getSubscriptionInvoices(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@ -157,12 +98,6 @@ export class SubscriptionsController {
// ==================== SIM Management Endpoints ==================== // ==================== SIM Management Endpoints ====================
@Get(":id/sim/debug") @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( async debugSimSubscription(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
@ -171,14 +106,6 @@ export class SubscriptionsController {
} }
@Get(":id/sim") @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( async getSimInfo(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
@ -187,12 +114,6 @@ export class SubscriptionsController {
} }
@Get(":id/sim/details") @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( async getSimDetails(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
@ -201,12 +122,6 @@ export class SubscriptionsController {
} }
@Get(":id/sim/usage") @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( async getSimUsage(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
@ -215,14 +130,6 @@ export class SubscriptionsController {
} }
@Get(":id/sim/top-up-history") @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( async getSimTopUpHistory(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@ -241,22 +148,6 @@ export class SubscriptionsController {
@Post(":id/sim/top-up") @Post(":id/sim/top-up")
@UsePipes(new ZodValidationPipe(simTopupRequestSchema)) @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( async topUpSim(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@ -268,23 +159,6 @@ export class SubscriptionsController {
@Post(":id/sim/change-plan") @Post(":id/sim/change-plan")
@UsePipes(new ZodValidationPipe(simChangePlanRequestSchema)) @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( async changeSimPlan(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@ -300,26 +174,6 @@ export class SubscriptionsController {
@Post(":id/sim/cancel") @Post(":id/sim/cancel")
@UsePipes(new ZodValidationPipe(simCancelRequestSchema)) @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( async cancelSim(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@ -330,28 +184,6 @@ export class SubscriptionsController {
} }
@Post(":id/sim/reissue-esim") @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( async reissueEsimProfile(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@ -363,25 +195,6 @@ export class SubscriptionsController {
@Post(":id/sim/features") @Post(":id/sim/features")
@UsePipes(new ZodValidationPipe(simFeaturesRequestSchema)) @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( async updateSimFeatures(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,

View File

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

View File

@ -29,7 +29,6 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"next": "15.5.0", "next": "15.5.0",
"openapi-fetch": "^0.13.5",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1", "react-dom": "19.1.1",
"react-hook-form": "^7.62.0", "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 { createClient, resolveBaseUrl } from "./runtime/client";
export type { ApiClient, AuthHeaderResolver, CreateClientOptions } from "./runtime/client"; export type { ApiClient, AuthHeaderResolver, CreateClientOptions } from "./runtime/client";
export { ApiError, isApiError } 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"; import type { ApiResponse } from "../response-helpers";
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
message: string, message: string,
@ -15,20 +13,46 @@ export class ApiError extends Error {
export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError; 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 = { type PathParams = Record<string, string | number>;
GET<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>; type QueryPrimitive = string | number | boolean;
POST<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>; type QueryParams = Record<
PUT<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>; string,
PATCH<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>; QueryPrimitive | QueryPrimitive[] | readonly QueryPrimitive[] | undefined
DELETE<T = unknown>(path: string, options?: unknown): Promise<ApiResponse<T>>; >;
};
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 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 = type EnvKey =
| "NEXT_PUBLIC_API_BASE" | "NEXT_PUBLIC_API_BASE"
| "NEXT_PUBLIC_API_URL" | "NEXT_PUBLIC_API_URL"
@ -56,7 +80,6 @@ const normalizeBaseUrl = (value: string) => {
return trimmed; return trimmed;
} }
// Avoid accidental double slashes when openapi-fetch joins with request path
return trimmed.replace(/\/+$/, ""); return trimmed.replace(/\/+$/, "");
}; };
@ -81,19 +104,56 @@ export const resolveBaseUrl = (baseUrl?: string) => {
return resolveBaseUrlFromEnv(); return resolveBaseUrlFromEnv();
}; };
export interface CreateClientOptions { const applyPathParams = (path: string, params?: PathParams): string => {
baseUrl?: string; if (!params) {
getAuthHeader?: AuthHeaderResolver; return path;
handleError?: (response: Response) => void | Promise<void>; }
enableCsrf?: boolean;
} 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 => { const getBodyMessage = (body: unknown): string | null => {
if (typeof body === "string") { if (typeof body === "string") {
return body; 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; const maybeMessage = (body as { message?: unknown }).message;
if (typeof maybeMessage === "string") { if (typeof maybeMessage === "string") {
return maybeMessage; return maybeMessage;
@ -132,15 +192,58 @@ async function defaultHandleError(response: Response) {
throw new ApiError(message, response, body); 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 { class CsrfTokenManager {
private token: string | null = null; private token: string | null = null;
private tokenPromise: Promise<string> | null = null; private tokenPromise: Promise<string> | null = null;
private baseUrl: string;
constructor(baseUrl: string) { constructor(private readonly baseUrl: string) {}
this.baseUrl = baseUrl;
}
async getToken(): Promise<string> { async getToken(): Promise<string> {
if (this.token) { if (this.token) {
@ -160,6 +263,11 @@ class CsrfTokenManager {
} }
} }
clearToken(): void {
this.token = null;
this.tokenPromise = null;
}
private async fetchToken(): Promise<string> { private async fetchToken(): Promise<string> {
const response = await fetch(`${this.baseUrl}/api/security/csrf/token`, { const response = await fetch(`${this.baseUrl}/api/security/csrf/token`, {
method: "GET", method: "GET",
@ -173,118 +281,111 @@ class CsrfTokenManager {
throw new Error(`Failed to fetch CSRF token: ${response.status}`); throw new Error(`Failed to fetch CSRF token: ${response.status}`);
} }
const data = await response.json(); const data: unknown = await response.json();
if (!data.success || !data.token) { if (!isCsrfTokenPayload(data)) {
throw new Error("Invalid CSRF token response"); throw new Error("Invalid CSRF token response");
} }
return data.token; 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 { export function createClient(options: CreateClientOptions = {}): ApiClient {
const baseUrl = resolveBaseUrl(options.baseUrl); const baseUrl = resolveBaseUrl(options.baseUrl);
const client = createOpenApiClient<paths>({ baseUrl }); const resolveAuthHeader = options.getAuthHeader;
const handleError = options.handleError ?? defaultHandleError; const handleError = options.handleError ?? defaultHandleError;
const enableCsrf = options.enableCsrf ?? true; const enableCsrf = options.enableCsrf ?? true;
const csrfManager = enableCsrf ? new CsrfTokenManager(baseUrl) : null; const csrfManager = enableCsrf ? new CsrfTokenManager(baseUrl) : null;
if (typeof client.use === "function") { const request = async <T>(
const resolveAuthHeader = options.getAuthHeader; 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 = { const queryString = buildQueryString(opts.params?.query);
async onRequest({ request }: MiddlewareCallbackParams) { if (queryString) {
if (!request) return; url.search = queryString;
}
const nextRequest = new Request(request, { const headers = new Headers(opts.headers);
credentials: "include",
});
// Add CSRF token for non-safe methods const credentials = opts.credentials ?? "include";
if (csrfManager && ["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) { const init: RequestInit = {
try { method,
const csrfToken = await csrfManager.getToken(); headers,
nextRequest.headers.set("X-CSRF-Token", csrfToken); credentials,
} catch (error) { signal: opts.signal,
console.warn("Failed to get CSRF token:", error); };
// Continue without CSRF token - let the server handle the error
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);
} }
} }
// Add auth header if available if (resolveAuthHeader && !headers.has("Authorization")) {
if (resolveAuthHeader && typeof nextRequest.headers?.has === "function") {
if (!nextRequest.headers.has("Authorization")) {
const headerValue = resolveAuthHeader(); const headerValue = resolveAuthHeader();
if (headerValue) { if (headerValue) {
nextRequest.headers.set("Authorization", headerValue); headers.set("Authorization", headerValue);
}
} }
} }
return nextRequest; if (
}, csrfManager &&
async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) { !opts.disableCsrf &&
// Handle CSRF token refresh on 403 errors !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);
}
}
const response = await fetch(url.toString(), init);
if (!response.ok) {
if (response.status === 403 && csrfManager) { if (response.status === 403 && csrfManager) {
try { try {
const errorText = await response.clone().text(); const bodyText = await response.clone().text();
if (errorText.includes("CSRF") || errorText.includes("csrf")) { if (bodyText.toLowerCase().includes("csrf")) {
// Clear the token so next request will fetch a new one
csrfManager.clearToken(); csrfManager.clearToken();
} }
} catch { } catch {
// Ignore errors when checking response body csrfManager.clearToken();
} }
} }
await handleError(response); 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);
client.use(middleware as never);
} }
const flexibleClient = client as ApiClient; const parsedBody = await parseResponseBody(response);
// Store references to original methods before overriding return {
const originalGET = client.GET.bind(client); data: (parsedBody as T | null | undefined) ?? null,
const originalPOST = client.POST.bind(client); };
const originalPUT = client.PUT.bind(client); };
const originalPATCH = client.PATCH.bind(client);
const originalDELETE = client.DELETE.bind(client);
flexibleClient.GET = (async (path: string, options?: unknown) => { return {
return (originalGET as FlexibleApiMethods["GET"])(path, options); GET: (path, opts) => request("GET", path, opts),
}) as ApiClient["GET"]; POST: (path, opts) => request("POST", path, opts),
PUT: (path, opts) => request("PUT", path, opts),
flexibleClient.POST = (async (path: string, options?: unknown) => { PATCH: (path, opts) => request("PATCH", path, opts),
return (originalPOST as FlexibleApiMethods["POST"])(path, options); DELETE: (path, opts) => request("DELETE", path, opts),
}) as ApiClient["POST"]; } satisfies ApiClient;
flexibleClient.PUT = (async (path: string, options?: unknown) => {
return (originalPUT as FlexibleApiMethods["PUT"])(path, options);
}) as ApiClient["PUT"];
flexibleClient.PATCH = (async (path: string, options?: unknown) => {
return (originalPATCH as FlexibleApiMethods["PATCH"])(path, options);
}) as ApiClient["PATCH"];
flexibleClient.DELETE = (async (path: string, options?: unknown) => {
return (originalDELETE as FlexibleApiMethods["DELETE"])(path, options);
}) as ApiClient["DELETE"];
return flexibleClient;
} }
export type { paths };

View File

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

View File

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

View File

@ -54,7 +54,6 @@
"update:safe": "pnpm update --recursive && pnpm audit && pnpm type-check", "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", "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", "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", "types:gen": "./scripts/generate-frontend-types.sh",
"codegen": "pnpm types:gen", "codegen": "pnpm types:gen",
"postinstall": "pnpm codegen || true" "postinstall": "pnpm codegen || true"

View File

@ -38,6 +38,12 @@ export const auditLogQuerySchema = paginationQuerySchema.extend({
export type AuditLogQuery = z.infer<typeof auditLogQuerySchema>; 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({ export const invoiceListQuerySchema = paginationQuerySchema.extend({
status: invoiceStatusEnum.optional(), status: invoiceStatusEnum.optional(),
}); });
@ -299,6 +305,18 @@ export type Invoice = z.infer<typeof invoiceSchema>;
export type Pagination = z.infer<typeof paginationSchema>; export type Pagination = z.infer<typeof paginationSchema>;
export type InvoiceList = z.infer<typeof invoiceListSchema>; 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 // ID MAPPING SCHEMAS
// ===================================================== // =====================================================

View File

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

View File

@ -1,16 +1,8 @@
#!/bin/bash #!/bin/bash
# 🎯 Automated Frontend Type Generation # Zod-based frontend type support
# This script ensures frontend types are always in sync with backend OpenAPI spec # OpenAPI generation has been removed; shared schemas live in @customer-portal/domain.
set -e set -e
echo "🔄 Generating OpenAPI spec from backend..." echo " Skipping OpenAPI generation: frontend consumes shared Zod schemas from @customer-portal/domain."
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"