diff --git a/README.md b/README.md index 4f91a3a3..61ce407d 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ A modern customer portal where users can self-register, log in, browse & buy sub - **jsforce** for Salesforce integration - **WHMCS** API client - **BullMQ** for async jobs with ioredis -- **OpenAPI/Swagger** for documentation +- **Zod-first validation** shared via the domain package ### Temporarily Disabled Modules @@ -65,10 +65,10 @@ A modern customer portal where users can self-register, log in, browse & buy sub new-portal-website/ ├── apps/ │ ├── portal/ # Next.js 15 frontend (React 19, Tailwind, shadcn/ui) -│ └── bff/ # NestJS 11 backend (Prisma, BullMQ, OpenAPI) +│ └── bff/ # NestJS 11 backend (Prisma, BullMQ, Zod validation) ├── packages/ │ ├── shared/ # Shared types and utilities -│ └── api-client/ # Generated OpenAPI client and types +│ └── api-client/ # Lightweight fetch helpers + shared Zod types ├── scripts/ │ ├── dev/ # Development management scripts │ └── prod/ # Production deployment scripts @@ -128,7 +128,6 @@ new-portal-website/ 4. **Access Your Applications** - **Frontend**: http://localhost:3000 - **Backend API**: http://localhost:4000/api - - **API Documentation**: http://localhost:4000/api/docs ### Development Commands @@ -167,23 +166,20 @@ Upload the tar files in Plesk → Docker → Images → Upload, then deploy usin ### API Client -The portal uses an integrated OpenAPI-based client with automatic type generation: - -1. **Generate OpenAPI spec** from BFF (runs automatically on build): - -```bash -pnpm openapi:gen -``` - -2. **Types are auto-generated** in `apps/portal/src/lib/api/__generated__/types.ts` - -3. **Use the client** in Portal: +The portal uses a lightweight fetch client that shares request/response contracts from +`@customer-portal/domain` and validates them with Zod: ```ts -import { apiClient } from "@/lib/api"; -// Client includes CSRF protection, auth headers, and error handling +import { apiClient, getDataOrThrow } from "@/lib/api"; +import type { DashboardSummary } from "@customer-portal/domain"; + +const response = await apiClient.GET("/api/me/summary"); +const summary = getDataOrThrow(response); ``` +Because the schemas and types live in the shared domain package there is no code +generation step—`pnpm types:gen` is now a no-op placeholder. + ### Environment Configuration - Local development: use the root `.env` (see `.env.example`). diff --git a/apps/bff/openapi/openapi.json b/apps/bff/openapi/openapi.json deleted file mode 100644 index 3d939243..00000000 --- a/apps/bff/openapi/openapi.json +++ /dev/null @@ -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" - ] - } - } - } -} \ No newline at end of file diff --git a/apps/bff/package.json b/apps/bff/package.json index de1962d5..65c26790 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -27,8 +27,7 @@ "db:generate": "prisma generate", "db:studio": "prisma studio", "db:reset": "prisma migrate reset", - "db:seed": "NODE_OPTIONS=\"--max-old-space-size=4096\" tsx prisma/seed.ts", - "openapi:gen": "NODE_OPTIONS=\"--max-old-space-size=4096\" tsx -r tsconfig-paths/register ./scripts/generate-openapi.ts" + "db:seed": "NODE_OPTIONS=\"--max-old-space-size=4096\" tsx prisma/seed.ts" }, "dependencies": { "@customer-portal/domain": "workspace:*", @@ -40,7 +39,6 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.6", - "@nestjs/swagger": "^11.2.0", "@nestjs/throttler": "^6.4.0", "@prisma/client": "^6.14.0", "@sendgrid/mail": "^8.1.6", diff --git a/apps/bff/scripts/generate-openapi.ts b/apps/bff/scripts/generate-openapi.ts deleted file mode 100644 index 6552c4b0..00000000 --- a/apps/bff/scripts/generate-openapi.ts +++ /dev/null @@ -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(); diff --git a/apps/bff/scripts/minimal.controller.ts b/apps/bff/scripts/minimal.controller.ts deleted file mode 100644 index fae22efc..00000000 --- a/apps/bff/scripts/minimal.controller.ts +++ /dev/null @@ -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" }; - } -} diff --git a/apps/bff/scripts/openapi.module.ts b/apps/bff/scripts/openapi.module.ts deleted file mode 100644 index 7cb3b165..00000000 --- a/apps/bff/scripts/openapi.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/bff/src/app/bootstrap.ts b/apps/bff/src/app/bootstrap.ts index f704aed5..536d8e00 100644 --- a/apps/bff/src/app/bootstrap.ts +++ b/apps/bff/src/app/bootstrap.ts @@ -1,7 +1,6 @@ import { type INestApplication } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { NestFactory } from "@nestjs/core"; -import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; import { Logger } from "nestjs-pino"; import helmet from "helmet"; import cookieParser from "cookie-parser"; @@ -128,22 +127,6 @@ export async function bootstrap(): Promise { // Rely on Nest's built-in shutdown hooks. External orchestrator will send signals. app.enableShutdownHooks(); - // Swagger documentation (only in non-production) - SETUP BEFORE GLOBAL PREFIX - if (configService.get("NODE_ENV") !== "production") { - const config = new DocumentBuilder() - .setTitle("Customer Portal API") - .setDescription("Backend for Frontend API for customer portal") - .setVersion("1.0") - .addBearerAuth() - .addCookieAuth("auth-cookie") - .addServer("http://localhost:4000", "Development server") - .addServer("https://api.yourdomain.com", "Production server") - .build(); - - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup("docs", app, document); - } - // API routing prefix is applied via RouterModule in AppModule for clarity and modern routing. const port = Number(configService.get("BFF_PORT", 4000)); @@ -161,9 +144,5 @@ export async function bootstrap(): Promise { logger.log("🔴 Redis connection configured"); } - if (configService.get("NODE_ENV") !== "production") { - logger.log(`📚 API Documentation: http://localhost:${port}/docs`); - } - return app; } diff --git a/apps/bff/src/core/config/auth-dev.config.ts b/apps/bff/src/core/config/auth-dev.config.ts index ca3a15ee..a95fff66 100644 --- a/apps/bff/src/core/config/auth-dev.config.ts +++ b/apps/bff/src/core/config/auth-dev.config.ts @@ -12,21 +12,21 @@ export interface DevAuthConfig { } export const createDevAuthConfig = (): DevAuthConfig => { - const isDevelopment = process.env.NODE_ENV !== 'production'; - + const isDevelopment = process.env.NODE_ENV !== "production"; + return { // Disable CSRF protection in development for easier testing - disableCsrf: isDevelopment && process.env.DISABLE_CSRF === 'true', - + disableCsrf: isDevelopment && process.env.DISABLE_CSRF === "true", + // Disable rate limiting in development - disableRateLimit: isDevelopment && process.env.DISABLE_RATE_LIMIT === 'true', - + disableRateLimit: isDevelopment && process.env.DISABLE_RATE_LIMIT === "true", + // Disable account locking in development - disableAccountLocking: isDevelopment && process.env.DISABLE_ACCOUNT_LOCKING === 'true', - + disableAccountLocking: isDevelopment && process.env.DISABLE_ACCOUNT_LOCKING === "true", + // Enable debug logs in development enableDebugLogs: isDevelopment, - + // Show detailed error messages in development simplifiedErrorMessages: isDevelopment, }; diff --git a/apps/bff/src/core/health/queue-health.controller.ts b/apps/bff/src/core/health/queue-health.controller.ts index 5395d998..48d7e5ce 100644 --- a/apps/bff/src/core/health/queue-health.controller.ts +++ b/apps/bff/src/core/health/queue-health.controller.ts @@ -1,9 +1,7 @@ import { Controller, Get } from "@nestjs/common"; -import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; import { WhmcsRequestQueueService } from "@bff/core/queue/services/whmcs-request-queue.service"; import { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service"; -@ApiTags("Health") @Controller("health/queues") export class QueueHealthController { constructor( @@ -12,14 +10,6 @@ export class QueueHealthController { ) {} @Get() - @ApiOperation({ - summary: "Get queue health status", - description: "Returns health status and metrics for WHMCS and Salesforce request queues", - }) - @ApiResponse({ - status: 200, - description: "Queue health status retrieved successfully", - }) getQueueHealth() { return { timestamp: new Date().toISOString(), @@ -36,14 +26,6 @@ export class QueueHealthController { } @Get("whmcs") - @ApiOperation({ - summary: "Get WHMCS queue metrics", - description: "Returns detailed metrics for the WHMCS request queue", - }) - @ApiResponse({ - status: 200, - description: "WHMCS queue metrics retrieved successfully", - }) getWhmcsQueueMetrics() { return { timestamp: new Date().toISOString(), @@ -53,15 +35,6 @@ export class QueueHealthController { } @Get("salesforce") - @ApiOperation({ - summary: "Get Salesforce queue metrics", - description: - "Returns detailed metrics for the Salesforce request queue including daily API usage", - }) - @ApiResponse({ - status: 200, - description: "Salesforce queue metrics retrieved successfully", - }) getSalesforceQueueMetrics() { return { timestamp: new Date().toISOString(), diff --git a/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts b/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts index 30308340..8869653e 100644 --- a/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts +++ b/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts @@ -2,7 +2,6 @@ import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/commo import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; import PQueue from "p-queue"; - export interface SalesforceQueueMetrics { totalRequests: number; completedRequests: number; @@ -67,7 +66,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest this.dailyUsageResetTime = this.getNextDayReset(); } - private async initializeQueues() { + private ensureQueuesInitialized(): { standardQueue: PQueue; longRunningQueue: PQueue } { if (!this.standardQueue || !this.longRunningQueue) { const concurrency = this.configService.get("SF_QUEUE_CONCURRENCY", 15); const longRunningConcurrency = this.configService.get( @@ -102,10 +101,22 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest // Set up queue event listeners this.setupQueueListeners(); } + + const standardQueue = this.standardQueue; + const longRunningQueue = this.longRunningQueue; + + if (!standardQueue || !longRunningQueue) { + throw new Error("Failed to initialize Salesforce queues"); + } + + return { + standardQueue, + longRunningQueue, + }; } - async onModuleInit() { - await this.initializeQueues(); + onModuleInit() { + this.ensureQueuesInitialized(); const concurrency = this.configService.get("SF_QUEUE_CONCURRENCY", 15); const longRunningConcurrency = this.configService.get( "SF_QUEUE_LONG_RUNNING_CONCURRENCY", @@ -128,16 +139,24 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest } async onModuleDestroy() { + const standardQueue = this.standardQueue; + const longRunningQueue = this.longRunningQueue; + + if (!standardQueue || !longRunningQueue) { + this.logger.debug("Salesforce Request Queue destroyed before initialization"); + return; + } + this.logger.log("Shutting down Salesforce Request Queue", { - standardPending: this.standardQueue.pending, - standardQueueSize: this.standardQueue.size, - longRunningPending: this.longRunningQueue.pending, - longRunningQueueSize: this.longRunningQueue.size, + standardPending: standardQueue.pending, + standardQueueSize: standardQueue.size, + longRunningPending: longRunningQueue.pending, + longRunningQueueSize: longRunningQueue.size, }); // Wait for pending requests to complete (with timeout) try { - await Promise.all([this.standardQueue.onIdle(), this.longRunningQueue.onIdle()]); + await Promise.all([standardQueue.onIdle(), longRunningQueue.onIdle()]); } catch (error) { this.logger.warn("Some Salesforce requests may not have completed during shutdown", { error: error instanceof Error ? error.message : String(error), @@ -152,18 +171,14 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest requestFn: () => Promise, options: SalesforceRequestOptions = {} ): Promise { - await this.initializeQueues(); + const { standardQueue, longRunningQueue } = this.ensureQueuesInitialized(); // Check daily API usage this.checkDailyUsage(); const startTime = Date.now(); const requestId = this.generateRequestId(); const isLongRunning = options.isLongRunning || false; - const queue = isLongRunning ? this.longRunningQueue : this.standardQueue; - - if (!queue) { - throw new Error("Queue not initialized"); - } + const queue = isLongRunning ? longRunningQueue : standardQueue; this.metrics.totalRequests++; this.metrics.dailyApiUsage++; @@ -326,17 +341,19 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest * Clear the queues (emergency use only) */ async clearQueues(): Promise { + const { standardQueue, longRunningQueue } = this.ensureQueuesInitialized(); + this.logger.warn("Clearing Salesforce request queues", { - standardQueueSize: this.standardQueue.size, - standardPending: this.standardQueue.pending, - longRunningQueueSize: this.longRunningQueue.size, - longRunningPending: this.longRunningQueue.pending, + standardQueueSize: standardQueue.size, + standardPending: standardQueue.pending, + longRunningQueueSize: longRunningQueue.size, + longRunningPending: longRunningQueue.pending, }); - this.standardQueue.clear(); - this.longRunningQueue.clear(); + standardQueue.clear(); + longRunningQueue.clear(); - await Promise.all([this.standardQueue.onIdle(), this.longRunningQueue.onIdle()]); + await Promise.all([standardQueue.onIdle(), longRunningQueue.onIdle()]); } private async executeWithRetry( @@ -449,6 +466,10 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest } private updateQueueMetrics(): void { + if (!this.standardQueue || !this.longRunningQueue) { + return; + } + this.metrics.queueSize = this.standardQueue.size + this.longRunningQueue.size; this.metrics.pendingRequests = this.standardQueue.pending + this.longRunningQueue.pending; diff --git a/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts b/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts index eead7b5d..ab9ca77c 100644 --- a/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts +++ b/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts @@ -2,7 +2,6 @@ import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/commo import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; import PQueue from "p-queue"; - export interface WhmcsQueueMetrics { totalRequests: number; completedRequests: number; @@ -58,7 +57,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { private readonly configService: ConfigService ) {} - private async initializeQueue() { + private ensureQueueInitialized(): PQueue { if (!this.queue) { const concurrency = this.configService.get("WHMCS_QUEUE_CONCURRENCY", 15); const intervalCap = this.configService.get("WHMCS_QUEUE_INTERVAL_CAP", 300); @@ -77,10 +76,16 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { // Set up queue event listeners this.setupQueueListeners(); } + + if (!this.queue) { + throw new Error("Failed to initialize WHMCS queue"); + } + + return this.queue; } - async onModuleInit() { - await this.initializeQueue(); + onModuleInit() { + this.ensureQueueInitialized(); const concurrency = this.configService.get("WHMCS_QUEUE_CONCURRENCY", 15); const intervalCap = this.configService.get("WHMCS_QUEUE_INTERVAL_CAP", 300); const timeout = this.configService.get("WHMCS_QUEUE_TIMEOUT_MS", 30000); @@ -112,26 +117,22 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { * Execute a WHMCS API request through the queue */ async execute(requestFn: () => Promise, options: WhmcsRequestOptions = {}): Promise { - await this.initializeQueue(); + const queue = this.ensureQueueInitialized(); const startTime = Date.now(); const requestId = this.generateRequestId(); - if (!this.queue) { - throw new Error("Queue not initialized"); - } - this.metrics.totalRequests++; this.updateQueueMetrics(); this.logger.debug("Queueing WHMCS request", { requestId, - queueSize: this.queue.size, - pending: this.queue.pending, + queueSize: queue.size, + pending: queue.pending, priority: options.priority || 0, }); try { - const result = await this.queue.add( + const result = await queue.add( async () => { const waitTime = Date.now() - startTime; this.recordWaitTime(waitTime); @@ -237,17 +238,19 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { * Clear the queue (emergency use only) */ async clearQueue(): Promise { - if (!this.queue) { + const queue = this.queue; + + if (!queue) { return; } this.logger.warn("Clearing WHMCS request queue", { - queueSize: this.queue.size, - pendingRequests: this.queue.pending, + queueSize: queue.size, + pendingRequests: queue.pending, }); - this.queue.clear(); - await this.queue.onIdle(); + queue.clear(); + await queue.onIdle(); } private async executeWithRetry( @@ -287,6 +290,10 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { } private setupQueueListeners(): void { + if (!this.queue) { + return; + } + this.queue.on("add", () => { this.updateQueueMetrics(); }); @@ -308,6 +315,10 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { } private updateQueueMetrics(): void { + if (!this.queue) { + return; + } + this.metrics.queueSize = this.queue.size; this.metrics.pendingRequests = this.queue.pending; diff --git a/apps/bff/src/core/security/controllers/csrf.controller.ts b/apps/bff/src/core/security/controllers/csrf.controller.ts index 079862bd..63d16df2 100644 --- a/apps/bff/src/core/security/controllers/csrf.controller.ts +++ b/apps/bff/src/core/security/controllers/csrf.controller.ts @@ -1,15 +1,14 @@ import { Controller, Get, Post, Req, Res, Inject } from "@nestjs/common"; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger"; import type { Request, Response } from "express"; import { Logger } from "nestjs-pino"; import { CsrfService } from "../services/csrf.service"; -interface AuthenticatedRequest extends Request { +type AuthenticatedRequest = Request & { user?: { id: string; sessionId?: string }; sessionID?: string; -} + cookies: Record; +}; -@ApiTags("Security") @Controller("security/csrf") export class CsrfController { constructor( @@ -18,22 +17,6 @@ export class CsrfController { ) {} @Get("token") - @ApiOperation({ - summary: "Get CSRF token", - description: "Generates and returns a new CSRF token for the current session", - }) - @ApiResponse({ - status: 200, - description: "CSRF token generated successfully", - schema: { - type: "object", - properties: { - success: { type: "boolean", example: true }, - token: { type: "string", example: "abc123..." }, - expiresAt: { type: "string", format: "date-time" }, - }, - }, - }) getCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) { const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined; const userId = req.user?.id; @@ -65,23 +48,6 @@ export class CsrfController { } @Post("refresh") - @ApiBearerAuth() - @ApiOperation({ - summary: "Refresh CSRF token", - description: "Invalidates current token and generates a new one for authenticated users", - }) - @ApiResponse({ - status: 200, - description: "CSRF token refreshed successfully", - schema: { - type: "object", - properties: { - success: { type: "boolean", example: true }, - token: { type: "string", example: "xyz789..." }, - expiresAt: { type: "string", format: "date-time" }, - }, - }, - }) refreshCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) { const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined; const userId = req.user?.id || "anonymous"; // Default for unauthenticated users @@ -116,31 +82,6 @@ export class CsrfController { } @Get("stats") - @ApiBearerAuth() - @ApiOperation({ - summary: "Get CSRF token statistics", - description: "Returns statistics about CSRF tokens (admin/monitoring endpoint)", - }) - @ApiResponse({ - status: 200, - description: "CSRF token statistics", - schema: { - type: "object", - properties: { - success: { type: "boolean", example: true }, - stats: { - type: "object", - properties: { - totalTokens: { type: "number", example: 150 }, - activeTokens: { type: "number", example: 120 }, - expiredTokens: { type: "number", example: 30 }, - cacheSize: { type: "number", example: 150 }, - maxCacheSize: { type: "number", example: 10000 }, - }, - }, - }, - }, - }) getCsrfStats(@Req() req: AuthenticatedRequest) { const userId = req.user?.id || "anonymous"; @@ -160,6 +101,8 @@ export class CsrfController { } private extractSessionId(req: AuthenticatedRequest): string | null { - return req.cookies?.["session-id"] || req.cookies?.["connect.sid"] || req.sessionID || null; + const cookies = req.cookies as Record | undefined; + const sessionCookie = cookies?.["session-id"] ?? cookies?.["connect.sid"]; + return sessionCookie ?? req.sessionID ?? null; } } diff --git a/apps/bff/src/core/security/middleware/csrf.middleware.ts b/apps/bff/src/core/security/middleware/csrf.middleware.ts index 854b52f0..f249c17b 100644 --- a/apps/bff/src/core/security/middleware/csrf.middleware.ts +++ b/apps/bff/src/core/security/middleware/csrf.middleware.ts @@ -5,11 +5,31 @@ import type { Request, Response, NextFunction } from "express"; import { CsrfService } from "../services/csrf.service"; import { devAuthConfig } from "../../config/auth-dev.config"; -interface CsrfRequest extends Request { +interface CsrfRequestBody { + _csrf?: string | string[]; + csrfToken?: string | string[]; + [key: string]: unknown; +} + +type QueryValue = string | string[] | undefined; + +type CsrfRequestQuery = Record; + +type CookieJar = Record; + +type BaseExpressRequest = Request< + Record, + unknown, + CsrfRequestBody, + CsrfRequestQuery +>; + +type CsrfRequest = Omit & { csrfToken?: string; user?: { id: string; sessionId?: string }; sessionID?: string; -} + cookies: CookieJar; +}; /** * CSRF Protection Middleware @@ -31,7 +51,7 @@ export class CsrfMiddleware implements NestMiddleware { // Paths that don't require CSRF protection this.exemptPaths = new Set([ "/api/auth/login", - "/api/auth/signup", + "/api/auth/signup", "/api/auth/refresh", "/api/auth/check-password-needed", "/api/auth/request-password-reset", @@ -159,7 +179,11 @@ export class CsrfMiddleware implements NestMiddleware { const sessionId = req.user?.sessionId || this.extractSessionId(req); const userId = req.user?.id; - const tokenData = this.csrfService.generateToken(existingSecret, sessionId || undefined, userId); + const tokenData = this.csrfService.generateToken( + existingSecret, + sessionId ?? undefined, + userId + ); this.setCsrfSecretCookie(res, tokenData.secret, tokenData.expiresAt); @@ -193,15 +217,19 @@ export class CsrfMiddleware implements NestMiddleware { } // 4. Request body (for form submissions) - if (req.body && typeof req.body === "object") { - token = req.body._csrf || req.body.csrfToken; - if (token) return token; + const bodyToken = + this.normalizeTokenValue(req.body._csrf) ?? this.normalizeTokenValue(req.body.csrfToken); + if (bodyToken) { + return bodyToken; } // 5. Query parameter (least secure, only for GET requests) if (req.method === "GET") { - token = (req.query._csrf as string) || (req.query.csrfToken as string); - if (token) return token; + const queryToken = + this.normalizeTokenValue(req.query._csrf) ?? this.normalizeTokenValue(req.query.csrfToken); + if (queryToken) { + return queryToken; + } } return null; @@ -209,12 +237,24 @@ export class CsrfMiddleware implements NestMiddleware { private extractSecretFromCookie(req: CsrfRequest): string | null { const cookieName = this.csrfService.getCookieName(); - return req.cookies?.[cookieName] || null; + const cookies = req.cookies; + if (!cookies) { + return null; + } + + const secret = cookies[cookieName]; + return typeof secret === "string" && secret.length > 0 ? secret : null; } private extractSessionId(req: CsrfRequest): string | null { // Try to extract session ID from various sources - return req.cookies?.["session-id"] || req.cookies?.["connect.sid"] || req.sessionID || null; + const cookies = req.cookies; + const sessionId = this.pickFirstString( + cookies?.["session-id"], + cookies?.["connect.sid"], + req.sessionID + ); + return sessionId ?? null; } private setCsrfSecretCookie(res: Response, secret: string, expiresAt?: Date): void { @@ -228,4 +268,31 @@ export class CsrfMiddleware implements NestMiddleware { res.cookie(this.csrfService.getCookieName(), secret, cookieOptions); } + + private normalizeTokenValue(value: string | string[] | undefined): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + if (Array.isArray(value)) { + for (const entry of value) { + const normalized = this.normalizeTokenValue(entry); + if (normalized) { + return normalized; + } + } + } + + return null; + } + + private pickFirstString(...values: Array): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.length > 0) { + return value; + } + } + return undefined; + } } diff --git a/apps/bff/src/core/security/services/csrf.service.ts b/apps/bff/src/core/security/services/csrf.service.ts index 0cbdb89f..cda799aa 100644 --- a/apps/bff/src/core/security/services/csrf.service.ts +++ b/apps/bff/src/core/security/services/csrf.service.ts @@ -16,6 +16,15 @@ export interface CsrfValidationResult { reason?: string; } +export interface CsrfTokenStats { + mode: "stateless"; + totalTokens: number; + activeTokens: number; + expiredTokens: number; + cacheSize: number; + maxCacheSize: number; +} + /** * Service for CSRF token generation and validation using deterministic HMAC tokens. */ @@ -133,12 +142,14 @@ export class CsrfService { this.logger.debug("invalidateUserTokens called - rotate cookie to enforce"); } - getTokenStats() { + getTokenStats(): CsrfTokenStats { return { mode: "stateless", totalTokens: 0, activeTokens: 0, expiredTokens: 0, + cacheSize: 0, + maxCacheSize: 0, }; } diff --git a/apps/bff/src/core/security/services/secure-error-mapper.service.ts b/apps/bff/src/core/security/services/secure-error-mapper.service.ts index 94267e09..1cb0f21a 100644 --- a/apps/bff/src/core/security/services/secure-error-mapper.service.ts +++ b/apps/bff/src/core/security/services/secure-error-mapper.service.ts @@ -418,8 +418,8 @@ export class SecureErrorMapperService { /database|sql|connection/i, /file|path|directory/i, /\s+at\s+.*\.js:\d+/i, // Stack traces - /[a-zA-Z]:[\\\/]/, // Windows paths - /[/][a-zA-Z0-9._\-/]+\.(js|ts|py|php)/i, // Unix paths + /[a-zA-Z]:[\\/]/, // Windows paths + /\/[a-zA-Z0-9._\-/]+\.(js|ts|py|php)/i, // Unix paths /\b(?:\d{1,3}\.){3}\d{1,3}\b/, // IP addresses /[A-Za-z0-9]{32,}/, // Long tokens/hashes ]; @@ -461,7 +461,7 @@ export class SecureErrorMapperService { // Remove stack traces .replace(/\s+at\s+.*/g, "") // Remove absolute paths - .replace(/[a-zA-Z]:[\\\/][^:]+/g, "[path]") + .replace(/[a-zA-Z]:[\\/][^:]+/g, "[path]") // Remove IP addresses .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]") // Remove URLs with credentials diff --git a/apps/bff/src/core/validation/zod-validation.filter.ts b/apps/bff/src/core/validation/zod-validation.filter.ts index 1f0fdf5b..ee5e2a77 100644 --- a/apps/bff/src/core/validation/zod-validation.filter.ts +++ b/apps/bff/src/core/validation/zod-validation.filter.ts @@ -2,6 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Inject } from "@nest import type { Request, Response } from "express"; import { Logger } from "nestjs-pino"; import { ZodValidationException } from "nestjs-zod"; +import type { ZodError, ZodIssue } from "zod"; interface ZodIssueResponse { path: string; @@ -18,12 +19,18 @@ export class ZodValidationExceptionFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); - const zodError = exception.getZodError(); - const issues: ZodIssueResponse[] = zodError.issues.map(issue => ({ - path: issue.path.join(".") || "root", - message: issue.message, - code: issue.code, - })); + const rawZodError = exception.getZodError(); + let issues: ZodIssueResponse[] = []; + + if (!this.isZodError(rawZodError)) { + this.logger.error("ZodValidationException did not contain a ZodError", { + path: request.url, + method: request.method, + providedType: typeof rawZodError, + }); + } else { + issues = this.mapIssues(rawZodError.issues); + } this.logger.warn("Request validation failed", { path: request.url, @@ -42,4 +49,18 @@ export class ZodValidationExceptionFilter implements ExceptionFilter { path: request.url, }); } + + private isZodError(error: unknown): error is ZodError { + return Boolean( + error && typeof error === "object" && Array.isArray((error as { issues?: unknown }).issues) + ); + } + + private mapIssues(issues: ZodIssue[]): ZodIssueResponse[] { + return issues.map(issue => ({ + path: issue.path.join(".") || "root", + message: issue.message, + code: issue.code, + })); + } } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 5bff6eaa..32df45b8 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -13,11 +13,11 @@ import { WhmcsCapturePaymentParams, } from "../types/whmcs-api.types"; -export interface InvoiceFilters { - status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; - page?: number; - limit?: number; -} +export type InvoiceFilters = Partial<{ + status: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; + page: number; + limit: number; +}>; @Injectable() export class WhmcsInvoiceService { @@ -62,26 +62,15 @@ export class WhmcsInvoiceService { const response = await this.connectionService.getInvoices(params); const transformed = this.transformInvoicesResponse(response, clientId, page, limit); - const parseResult = invoiceListSchema.safeParse(transformed); - if (!parseResult.success) { - this.logger.error("Failed to parse invoices response", { - error: parseResult.error.issues, - clientId, - page, - limit, - }); - throw new Error("Invalid invoice response from WHMCS"); - } - - const result = parseResult.data; + const result = invoiceListSchema.parse(transformed); // Cache the result - await this.cacheService.setInvoicesList(userId, page, limit, status, result as any); + await this.cacheService.setInvoicesList(userId, page, limit, status, result); this.logger.log( `Fetched ${result.invoices.length} invoices for client ${clientId}, page ${page}` ); - return result as any; + return result; } catch (error) { this.logger.error(`Failed to fetch invoices for client ${clientId}`, { error: getErrorMessage(error), @@ -110,16 +99,7 @@ export class WhmcsInvoiceService { try { // Get detailed invoice with items const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id); - const parseResult = invoiceSchema.safeParse(detailedInvoice); - if (!parseResult.success) { - this.logger.error("Failed to parse detailed invoice", { - error: parseResult.error.issues, - invoiceId: invoice.id, - clientId, - }); - throw new Error("Invalid invoice data"); - } - return parseResult.data; + return invoiceSchema.parse(detailedInvoice); } catch (error) { this.logger.warn( `Failed to fetch details for invoice ${invoice.id}`, @@ -132,7 +112,7 @@ export class WhmcsInvoiceService { ); const result: InvoiceList = { - invoices: invoicesWithItems as any, + invoices: invoicesWithItems, pagination: invoiceList.pagination, }; diff --git a/apps/bff/src/modules/auth/auth-admin.controller.ts b/apps/bff/src/modules/auth/auth-admin.controller.ts index c0f260c1..267653fe 100644 --- a/apps/bff/src/modules/auth/auth-admin.controller.ts +++ b/apps/bff/src/modules/auth/auth-admin.controller.ts @@ -6,15 +6,21 @@ import { UseGuards, Query, BadRequestException, + UsePipes, } from "@nestjs/common"; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger"; import { AdminGuard } from "./guards/admin.guard"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service"; import { UsersService } from "@bff/modules/users/users.service"; import { TokenMigrationService } from "@bff/modules/auth/infra/token/token-migration.service"; +import { ZodValidationPipe } from "@bff/core/validation"; +import { + auditLogQuerySchema, + dryRunQuerySchema, + type AuditLogQuery, + type DryRunQuery, +} from "@customer-portal/domain"; +import { z } from "zod"; -@ApiTags("auth-admin") -@ApiBearerAuth() @UseGuards(AdminGuard) @Controller("auth/admin") export class AuthAdminController { @@ -25,41 +31,27 @@ export class AuthAdminController { ) {} @Get("audit-logs") - @ApiOperation({ summary: "Get audit logs (admin only)" }) - @ApiResponse({ status: 200, description: "Audit logs retrieved" }) - async getAuditLogs( - @Query("page") page: string = "1", - @Query("limit") limit: string = "50", - @Query("action") action?: AuditAction, - @Query("userId") userId?: string - ) { - const pageNum = parseInt(page, 10); - const limitNum = parseInt(limit, 10); - if (Number.isNaN(pageNum) || Number.isNaN(limitNum) || pageNum < 1 || limitNum < 1) { - throw new BadRequestException("Invalid pagination parameters"); - } - + @UsePipes(new ZodValidationPipe(auditLogQuerySchema)) + async getAuditLogs(@Query() query: AuditLogQuery) { const { logs, total } = await this.auditService.getAuditLogs({ - page: pageNum, - limit: limitNum, - action, - userId, + page: query.page, + limit: query.limit, + action: query.action as AuditAction | undefined, + userId: query.userId, }); return { logs, pagination: { - page: pageNum, - limit: limitNum, + page: query.page, + limit: query.limit, total, - totalPages: Math.ceil(total / limitNum), + totalPages: Math.ceil(total / query.limit), }, }; } @Post("unlock-account/:userId") - @ApiOperation({ summary: "Unlock user account (admin only)" }) - @ApiResponse({ status: 200, description: "Account unlocked" }) async unlockAccount(@Param("userId") userId: string) { const user = await this.usersService.findById(userId); if (!user) { @@ -83,24 +75,19 @@ export class AuthAdminController { } @Get("security-stats") - @ApiOperation({ summary: "Get security statistics (admin only)" }) - @ApiResponse({ status: 200, description: "Security stats retrieved" }) async getSecurityStats() { return this.auditService.getSecurityStats(); } @Get("token-migration/status") - @ApiOperation({ summary: "Get token migration status (admin only)" }) - @ApiResponse({ status: 200, description: "Migration status retrieved" }) async getTokenMigrationStatus() { return this.tokenMigrationService.getMigrationStatus(); } @Post("token-migration/run") - @ApiOperation({ summary: "Run token migration (admin only)" }) - @ApiResponse({ status: 200, description: "Migration completed" }) - async runTokenMigration(@Query("dryRun") dryRun: string = "true") { - const isDryRun = dryRun.toLowerCase() !== "false"; + @UsePipes(new ZodValidationPipe(dryRunQuerySchema)) + async runTokenMigration(@Query() query: DryRunQuery) { + const isDryRun = query.dryRun ?? true; const stats = await this.tokenMigrationService.migrateExistingTokens(isDryRun); await this.auditService.log({ @@ -121,10 +108,9 @@ export class AuthAdminController { } @Post("token-migration/cleanup") - @ApiOperation({ summary: "Clean up orphaned tokens (admin only)" }) - @ApiResponse({ status: 200, description: "Cleanup completed" }) - async cleanupOrphanedTokens(@Query("dryRun") dryRun: string = "true") { - const isDryRun = dryRun.toLowerCase() !== "false"; + @UsePipes(new ZodValidationPipe(dryRunQuerySchema)) + async cleanupOrphanedTokens(@Query() query: DryRunQuery) { + const isDryRun = query.dryRun ?? true; const stats = await this.tokenMigrationService.cleanupOrphanedTokens(isDryRun); await this.auditService.log({ diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index ea488fa6..e8051f50 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -17,7 +17,6 @@ import { LocalAuthGuard } from "./guards/local-auth.guard"; import { AuthThrottleGuard } from "./guards/auth-throttle.guard"; import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor"; -import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from "@nestjs/swagger"; import { Public } from "./decorators/public.decorator"; import { ZodValidationPipe } from "@bff/core/validation"; @@ -78,7 +77,6 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => { return Math.max(0, expiresAt - Date.now()); }; -@ApiTags("auth") @Controller("auth") export class AuthController { constructor(private authFacade: AuthFacade) {} @@ -107,19 +105,12 @@ export class AuthController { @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 20, ttl: 600000 } }) // 20 validations per 10 minutes per IP @UsePipes(new ZodValidationPipe(validateSignupRequestSchema)) - @ApiOperation({ summary: "Validate customer number for signup" }) - @ApiResponse({ status: 200, description: "Validation successful" }) - @ApiResponse({ status: 409, description: "Customer already has account" }) - @ApiResponse({ status: 400, description: "Customer number not found" }) - @ApiResponse({ status: 429, description: "Too many validation attempts" }) async validateSignup(@Body() validateData: ValidateSignupRequestInput, @Req() req: Request) { return this.authFacade.validateSignup(validateData, req); } @Public() @Get("health-check") - @ApiOperation({ summary: "Check auth service health and integrations" }) - @ApiResponse({ status: 200, description: "Health check results" }) async healthCheck() { return this.authFacade.healthCheck(); } @@ -130,8 +121,6 @@ export class AuthController { @Throttle({ default: { limit: 20, ttl: 600000 } }) // 20 validations per 10 minutes per IP @UsePipes(new ZodValidationPipe(signupRequestSchema)) @HttpCode(200) - @ApiOperation({ summary: "Validate full signup data without creating anything" }) - @ApiResponse({ status: 200, description: "Preflight results with next action guidance" }) async signupPreflight(@Body() signupData: SignupRequestInput) { return this.authFacade.signupPreflight(signupData); } @@ -139,8 +128,6 @@ export class AuthController { @Public() @Post("account-status") @UsePipes(new ZodValidationPipe(accountStatusRequestSchema)) - @ApiOperation({ summary: "Get account status by email" }) - @ApiOkResponse({ description: "Account status" }) async accountStatus(@Body() body: AccountStatusRequestInput) { return this.authFacade.getAccountStatus(body.email); } @@ -150,10 +137,6 @@ export class AuthController { @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 signups per 15 minutes per IP (reasonable for account creation) @UsePipes(new ZodValidationPipe(signupRequestSchema)) - @ApiOperation({ summary: "Create new user account" }) - @ApiResponse({ status: 201, description: "User created successfully" }) - @ApiResponse({ status: 409, description: "User already exists" }) - @ApiResponse({ status: 429, description: "Too many signup attempts" }) async signup( @Body() signupData: SignupRequestInput, @Req() req: Request, @@ -168,10 +151,6 @@ export class AuthController { @UseGuards(LocalAuthGuard, FailedLoginThrottleGuard) @UseInterceptors(LoginResultInterceptor) @Post("login") - @ApiOperation({ summary: "Authenticate user" }) - @ApiResponse({ status: 200, description: "Login successful" }) - @ApiResponse({ status: 401, description: "Invalid credentials" }) - @ApiResponse({ status: 429, description: "Too many login attempts" }) async login( @Req() req: Request & { user: { id: string; email: string; role: string } }, @Res({ passthrough: true }) res: Response @@ -182,8 +161,6 @@ export class AuthController { } @Post("logout") - @ApiOperation({ summary: "Logout user" }) - @ApiResponse({ status: 200, description: "Logout successful" }) async logout( @Req() req: RequestWithCookies & { user: { id: string } }, @Res({ passthrough: true }) res: Response @@ -198,10 +175,6 @@ export class AuthController { @Post("refresh") @Throttle({ default: { limit: 10, ttl: 300000 } }) // 10 attempts per 5 minutes per IP @UsePipes(new ZodValidationPipe(refreshTokenRequestSchema)) - @ApiOperation({ summary: "Refresh access token using refresh token" }) - @ApiResponse({ status: 200, description: "Token refreshed successfully" }) - @ApiResponse({ status: 401, description: "Invalid refresh token" }) - @ApiResponse({ status: 429, description: "Too many refresh attempts" }) async refreshToken( @Body() body: RefreshTokenRequestInput, @Req() req: RequestWithCookies, @@ -221,13 +194,6 @@ export class AuthController { @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 5, ttl: 600000 } }) // 5 attempts per 10 minutes per IP (industry standard) @UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) - @ApiOperation({ summary: "Link existing WHMCS user" }) - @ApiResponse({ - status: 200, - description: "WHMCS account linked successfully", - }) - @ApiResponse({ status: 401, description: "Invalid WHMCS credentials" }) - @ApiResponse({ status: 429, description: "Too many link attempts" }) async linkWhmcs(@Body() linkData: LinkWhmcsRequestInput, @Req() _req: Request) { return this.authFacade.linkWhmcsUser(linkData); } @@ -237,10 +203,6 @@ export class AuthController { @UseGuards(AuthThrottleGuard) @Throttle({ default: { limit: 5, ttl: 600000 } }) // 5 attempts per 10 minutes per IP+UA (industry standard) @UsePipes(new ZodValidationPipe(setPasswordRequestSchema)) - @ApiOperation({ summary: "Set password for linked user" }) - @ApiResponse({ status: 200, description: "Password set successfully" }) - @ApiResponse({ status: 401, description: "User not found" }) - @ApiResponse({ status: 429, description: "Too many password attempts" }) async setPassword( @Body() setPasswordData: SetPasswordRequestInput, @Req() _req: Request, @@ -255,8 +217,6 @@ export class AuthController { @Post("check-password-needed") @UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema)) @HttpCode(200) - @ApiOperation({ summary: "Check if user needs to set password" }) - @ApiResponse({ status: 200, description: "Password status checked" }) async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestInput) { return this.authFacade.checkPasswordNeeded(data.email); } @@ -265,8 +225,6 @@ export class AuthController { @Post("request-password-reset") @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 attempts per 15 minutes (standard for password operations) @UsePipes(new ZodValidationPipe(passwordResetRequestSchema)) - @ApiOperation({ summary: "Request password reset email" }) - @ApiResponse({ status: 200, description: "Reset email sent if account exists" }) async requestPasswordReset(@Body() body: PasswordResetRequestInput) { await this.authFacade.requestPasswordReset(body.email, req); return { message: "If an account exists, a reset email has been sent" }; @@ -276,8 +234,6 @@ export class AuthController { @Post("reset-password") @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 attempts per 15 minutes (standard for password operations) @UsePipes(new ZodValidationPipe(passwordResetSchema)) - @ApiOperation({ summary: "Reset password with token" }) - @ApiResponse({ status: 200, description: "Password reset successful" }) async resetPassword(@Body() body: PasswordResetInput, @Res({ passthrough: true }) res: Response) { const result = await this.authFacade.resetPassword(body.token, body.password); this.setAuthCookies(res, result.tokens); @@ -287,8 +243,6 @@ export class AuthController { @Post("change-password") @Throttle({ default: { limit: 5, ttl: 300000 } }) @UsePipes(new ZodValidationPipe(changePasswordRequestSchema)) - @ApiOperation({ summary: "Change password (authenticated)" }) - @ApiResponse({ status: 200, description: "Password changed successfully" }) async changePassword( @Req() req: Request & { user: { id: string } }, @Body() body: ChangePasswordRequestInput, @@ -305,7 +259,6 @@ export class AuthController { } @Get("me") - @ApiOperation({ summary: "Get current authentication status" }) getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role: string } }) { // Return basic auth info only - full profile should use /api/me return { @@ -320,12 +273,6 @@ export class AuthController { @Post("sso-link") @UsePipes(new ZodValidationPipe(ssoLinkRequestSchema)) - @ApiOperation({ summary: "Create SSO link to WHMCS" }) - @ApiResponse({ status: 200, description: "SSO link created successfully" }) - @ApiResponse({ - status: 404, - description: "User not found or not linked to WHMCS", - }) async createSsoLink( @Req() req: Request & { user: { id: string } }, @Body() body: SsoLinkRequestInput diff --git a/apps/bff/src/modules/catalog/catalog.controller.ts b/apps/bff/src/modules/catalog/catalog.controller.ts index a5bdb2fc..71ea62ee 100644 --- a/apps/bff/src/modules/catalog/catalog.controller.ts +++ b/apps/bff/src/modules/catalog/catalog.controller.ts @@ -1,5 +1,4 @@ import { Controller, Get, Request } from "@nestjs/common"; -import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger"; import type { InternetAddonCatalogItem, InternetInstallationCatalogItem, @@ -12,9 +11,7 @@ import { InternetCatalogService } from "./services/internet-catalog.service"; import { SimCatalogService } from "./services/sim-catalog.service"; import { VpnCatalogService } from "./services/vpn-catalog.service"; -@ApiTags("catalog") @Controller("catalog") -@ApiBearerAuth() export class CatalogController { constructor( private internetCatalog: InternetCatalogService, @@ -23,10 +20,7 @@ export class CatalogController { ) {} @Get("internet/plans") - @ApiOperation({ summary: "Get Internet plans filtered by customer eligibility" }) - async getInternetPlans( - @Request() req: { user: { id: string } } - ): Promise<{ + async getInternetPlans(@Request() req: { user: { id: string } }): Promise<{ plans: InternetPlanCatalogItem[]; installations: InternetInstallationCatalogItem[]; addons: InternetAddonCatalogItem[]; @@ -36,31 +30,28 @@ export class CatalogController { // Fallback to all catalog data if no user context return this.internetCatalog.getCatalogData(); } - + // Get user-specific plans but all installations and addons const [plans, installations, addons] = await Promise.all([ this.internetCatalog.getPlansForUser(userId), this.internetCatalog.getInstallations(), this.internetCatalog.getAddons(), ]); - + return { plans, installations, addons }; } @Get("internet/addons") - @ApiOperation({ summary: "Get Internet add-ons" }) async getInternetAddons(): Promise { return this.internetCatalog.getAddons(); } @Get("internet/installations") - @ApiOperation({ summary: "Get Internet installations" }) async getInternetInstallations(): Promise { return this.internetCatalog.getInstallations(); } @Get("sim/plans") - @ApiOperation({ summary: "Get SIM plans filtered by user's existing services" }) async getSimPlans(@Request() req: { user: { id: string } }): Promise { const userId = req.user?.id; if (!userId) { @@ -72,25 +63,21 @@ export class CatalogController { } @Get("sim/activation-fees") - @ApiOperation({ summary: "Get SIM activation fees" }) async getSimActivationFees(): Promise { return this.simCatalog.getActivationFees(); } @Get("sim/addons") - @ApiOperation({ summary: "Get SIM add-ons" }) async getSimAddons(): Promise { return this.simCatalog.getAddons(); } @Get("vpn/plans") - @ApiOperation({ summary: "Get VPN plans" }) async getVpnPlans(): Promise { return this.vpnCatalog.getPlans(); } @Get("vpn/activation-fees") - @ApiOperation({ summary: "Get VPN activation fees" }) async getVpnActivationFees(): Promise { return this.vpnCatalog.getActivationFees(); } diff --git a/apps/bff/src/modules/invoices/invoices.controller.ts b/apps/bff/src/modules/invoices/invoices.controller.ts index 9540081d..0ef43088 100644 --- a/apps/bff/src/modules/invoices/invoices.controller.ts +++ b/apps/bff/src/modules/invoices/invoices.controller.ts @@ -11,16 +11,6 @@ import { BadRequestException, UsePipes, } from "@nestjs/common"; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiOkResponse, - ApiQuery, - ApiBearerAuth, - ApiParam, -} from "@nestjs/swagger"; -import { createZodDto } from "nestjs-zod"; import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; @@ -36,23 +26,13 @@ import type { InvoicePaymentLink, InvoiceListQuery, } from "@customer-portal/domain"; -import { - invoiceSchema, - invoiceListSchema, - invoiceListQuerySchema, -} from "@customer-portal/domain"; - -// ✅ CLEAN: DTOs only for OpenAPI generation -class InvoiceDto extends createZodDto(invoiceSchema) {} -class InvoiceListDto extends createZodDto(invoiceListSchema) {} +import { invoiceListQuerySchema } from "@customer-portal/domain"; interface AuthenticatedRequest { user: { id: string }; } -@ApiTags("invoices") @Controller("invoices") -@ApiBearerAuth() export class InvoicesController { constructor( private readonly invoicesService: InvoicesOrchestratorService, @@ -61,33 +41,6 @@ export class InvoicesController { ) {} @Get() - @ApiOperation({ - summary: "Get paginated list of user invoices", - description: - "Retrieves invoices for the authenticated user with pagination and optional status filtering", - }) - @ApiQuery({ - name: "page", - required: false, - type: Number, - description: "Page number (default: 1)", - }) - @ApiQuery({ - name: "limit", - required: false, - type: Number, - description: "Items per page (default: 10)", - }) - @ApiQuery({ - name: "status", - required: false, - type: String, - description: "Filter by invoice status", - }) - @ApiOkResponse({ - description: "List of invoices with pagination", - type: InvoiceListDto - }) @UsePipes(new ZodValidationPipe(invoiceListQuerySchema)) async getInvoices( @Request() req: AuthenticatedRequest, @@ -97,11 +50,6 @@ export class InvoicesController { } @Get("payment-methods") - @ApiOperation({ - summary: "Get user payment methods", - description: "Retrieves all saved payment methods for the authenticated user", - }) - @ApiOkResponse({ description: "List of payment methods" }) async getPaymentMethods(@Request() req: AuthenticatedRequest): Promise { const mapping = await this.mappingsService.findByUserId(req.user.id); if (!mapping?.whmcsClientId) { @@ -111,22 +59,12 @@ export class InvoicesController { } @Get("payment-gateways") - @ApiOperation({ - summary: "Get available payment gateways", - description: "Retrieves all active payment gateways available for payments", - }) - @ApiOkResponse({ description: "List of payment gateways" }) async getPaymentGateways(): Promise { return this.whmcsService.getPaymentGateways(); } @Post("payment-methods/refresh") @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Refresh payment methods cache", - description: "Invalidates and refreshes payment methods cache for the current user", - }) - @ApiOkResponse({ description: "Payment methods cache refreshed" }) async refreshPaymentMethods(@Request() req: AuthenticatedRequest): Promise { // Invalidate cache first await this.whmcsService.invalidatePaymentMethodsCache(req.user.id); @@ -140,16 +78,6 @@ export class InvoicesController { } @Get(":id") - @ApiOperation({ - summary: "Get invoice details by ID", - description: "Retrieves detailed information for a specific invoice", - }) - @ApiParam({ name: "id", type: Number, description: "Invoice ID" }) - @ApiOkResponse({ - description: "Invoice details", - type: InvoiceDto - }) - @ApiResponse({ status: 404, description: "Invoice not found" }) async getInvoiceById( @Request() req: AuthenticatedRequest, @Param("id", ParseIntPipe) invoiceId: number @@ -162,13 +90,6 @@ export class InvoicesController { } @Get(":id/subscriptions") - @ApiOperation({ - summary: "Get subscriptions related to an invoice", - description: "Retrieves all subscriptions that are referenced in the invoice items", - }) - @ApiParam({ name: "id", type: Number, description: "Invoice ID" }) - @ApiOkResponse({ description: "List of related subscriptions" }) - @ApiResponse({ status: 404, description: "Invoice not found" }) getInvoiceSubscriptions( @Request() req: AuthenticatedRequest, @Param("id", ParseIntPipe) invoiceId: number @@ -184,19 +105,6 @@ export class InvoicesController { @Post(":id/sso-link") @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Create SSO link for invoice", - description: "Generates a single sign-on link to view/pay the invoice or download PDF in WHMCS", - }) - @ApiParam({ name: "id", type: Number, description: "Invoice ID" }) - @ApiQuery({ - name: "target", - required: false, - enum: ["view", "download", "pay"], - description: "Link target: view invoice, download PDF, or go to payment page (default: view)", - }) - @ApiOkResponse({ description: "SSO link created successfully" }) - @ApiResponse({ status: 404, description: "Invoice not found" }) async createSsoLink( @Request() req: AuthenticatedRequest, @Param("id", ParseIntPipe) invoiceId: number, @@ -230,26 +138,6 @@ export class InvoicesController { @Post(":id/payment-link") @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Create payment link for invoice with payment method", - description: - "Generates a payment link for the invoice with a specific payment method or gateway", - }) - @ApiParam({ name: "id", type: Number, description: "Invoice ID" }) - @ApiQuery({ - name: "paymentMethodId", - required: false, - type: Number, - description: "Payment method ID", - }) - @ApiQuery({ - name: "gatewayName", - required: false, - type: String, - description: "Payment gateway name", - }) - @ApiOkResponse({ description: "Payment link created successfully" }) - @ApiResponse({ status: 404, description: "Invoice not found" }) async createPaymentLink( @Request() req: AuthenticatedRequest, @Param("id", ParseIntPipe) invoiceId: number, diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index fb691ba0..8eb06217 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -1,12 +1,15 @@ import { Body, Controller, Get, Param, Post, Request, UsePipes } from "@nestjs/common"; import { OrderOrchestrator } from "./services/order-orchestrator.service"; -import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import { Logger } from "nestjs-pino"; import { ZodValidationPipe } from "@bff/core/validation"; -import { createOrderRequestSchema, type CreateOrderRequest } from "@customer-portal/domain"; +import { + createOrderRequestSchema, + sfOrderIdParamSchema, + type CreateOrderRequest, + type SfOrderIdParam, +} from "@customer-portal/domain"; -@ApiTags("orders") @Controller("orders") export class OrdersController { constructor( @@ -14,12 +17,8 @@ export class OrdersController { private readonly logger: Logger ) {} - @ApiBearerAuth() @Post() @UsePipes(new ZodValidationPipe(createOrderRequestSchema)) - @ApiOperation({ summary: "Create Salesforce Order" }) - @ApiResponse({ status: 201, description: "Order created successfully" }) - @ApiResponse({ status: 400, description: "Invalid request data" }) async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequest) { this.logger.log( { @@ -45,19 +44,15 @@ export class OrdersController { } } - @ApiBearerAuth() @Get("user") - @ApiOperation({ summary: "Get user's orders" }) async getUserOrders(@Request() req: RequestWithUser) { return this.orderOrchestrator.getOrdersForUser(req.user.id); } - @ApiBearerAuth() @Get(":sfOrderId") - @ApiOperation({ summary: "Get order summary/status" }) - @ApiParam({ name: "sfOrderId", type: String }) - async get(@Request() req: RequestWithUser, @Param("sfOrderId") sfOrderId: string) { - return this.orderOrchestrator.getOrder(sfOrderId); + @UsePipes(new ZodValidationPipe(sfOrderIdParamSchema)) + async get(@Request() req: RequestWithUser, @Param() params: SfOrderIdParam) { + return this.orderOrchestrator.getOrder(params.sfOrderId); } // Note: Order provisioning has been moved to SalesforceProvisioningController diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts index 9130b043..846f1fe3 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts @@ -6,6 +6,8 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SalesforceOrderRecord } from "@customer-portal/domain"; import { SalesforceFieldMapService, type SalesforceFieldMap } from "@bff/core/config/field-map"; +import { orderFulfillmentValidationSchema } from "@customer-portal/domain"; +import { sfOrderIdParamSchema } from "@customer-portal/domain"; type OrderStringFieldKey = "activationStatus"; export interface OrderFulfillmentValidationResult { diff --git a/apps/bff/src/modules/orders/services/order-item-builder.service.ts b/apps/bff/src/modules/orders/services/order-item-builder.service.ts index 430070aa..455383bd 100644 --- a/apps/bff/src/modules/orders/services/order-item-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-item-builder.service.ts @@ -2,6 +2,9 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nes import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { OrderPricebookService } from "./order-pricebook.service"; +import type { PrismaService } from "@bff/infra/database/prisma.service"; +import { z } from "zod"; +import { createOrderRequestSchema } from "@customer-portal/domain"; /** * Handles building order items from SKU data @@ -11,7 +14,8 @@ export class OrderItemBuilder { constructor( @Inject(Logger) private readonly logger: Logger, private readonly sf: SalesforceConnection, - private readonly pricebookService: OrderPricebookService + private readonly pricebookService: OrderPricebookService, + private readonly prisma: PrismaService ) {} /** @@ -22,8 +26,9 @@ export class OrderItemBuilder { skus: string[], pricebookId: string ): Promise { - if (skus.length === 0) { - throw new BadRequestException("No products specified for order"); + const { skus: validatedSkus } = buildItemsSchema.parse({ skus }); + if (pricebookId.length === 0) { + throw new BadRequestException("Product SKU is required"); } this.logger.log({ orderId, skus }, "Creating OrderItems from SKU array"); @@ -31,7 +36,7 @@ export class OrderItemBuilder { const metaMap = await this.pricebookService.fetchProductMeta(pricebookId, skus); // Create OrderItems for each SKU - for (const sku of skus) { + for (const sku of validatedSkus) { const normalizedSkuValue = sku?.trim(); if (!normalizedSkuValue) { this.logger.error({ orderId }, "Encountered empty SKU while creating order items"); @@ -125,3 +130,5 @@ export class OrderItemBuilder { }; } } + +const buildItemsSchema = createOrderRequestSchema.pick({ skus: true }); diff --git a/apps/bff/src/modules/subscriptions/sim-orders.controller.ts b/apps/bff/src/modules/subscriptions/sim-orders.controller.ts index 4db0d69b..b3ee1b0a 100644 --- a/apps/bff/src/modules/subscriptions/sim-orders.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-orders.controller.ts @@ -1,19 +1,13 @@ import { Body, Controller, Post, Request } from "@nestjs/common"; -import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import { SimOrderActivationService } from "./sim-order-activation.service"; import type { SimOrderActivationRequest } from "./sim-order-activation.service"; -@ApiTags("sim-orders") -@ApiBearerAuth() @Controller("subscriptions/sim/orders") export class SimOrdersController { constructor(private readonly activation: SimOrderActivationService) {} @Post("activate") - @ApiOperation({ summary: "Create invoice, capture payment, and activate SIM in Freebit" }) - @ApiBody({ description: "SIM activation order payload" }) - @ApiResponse({ status: 200, description: "Activation processed" }) async activate(@Request() req: RequestWithUser, @Body() body: SimOrderActivationRequest) { const result = await this.activation.activate(req.user.id, body); return result; diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index 09ebb683..a084d90b 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -10,16 +10,6 @@ import { BadRequestException, UsePipes, } from "@nestjs/common"; -import { - ApiTags, - ApiOperation, - ApiResponse, - ApiOkResponse, - ApiQuery, - ApiBearerAuth, - ApiParam, - ApiBody, -} from "@nestjs/swagger"; import { SubscriptionsService } from "./subscriptions.service"; import { SimManagementService } from "./sim-management.service"; @@ -43,9 +33,7 @@ import { import { ZodValidationPipe } from "@bff/core/validation"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; -@ApiTags("subscriptions") @Controller("subscriptions") -@ApiBearerAuth() export class SubscriptionsController { constructor( private readonly subscriptionsService: SubscriptionsService, @@ -53,17 +41,6 @@ export class SubscriptionsController { ) {} @Get() - @ApiOperation({ - summary: "Get all user subscriptions", - description: "Retrieves all subscriptions/services for the authenticated user", - }) - @ApiQuery({ - name: "status", - required: false, - type: String, - description: "Filter by subscription status", - }) - @ApiOkResponse({ description: "List of user subscriptions" }) @UsePipes(new ZodValidationPipe(subscriptionQuerySchema)) async getSubscriptions( @Request() req: RequestWithUser, @@ -77,21 +54,11 @@ export class SubscriptionsController { } @Get("active") - @ApiOperation({ - summary: "Get active subscriptions only", - description: "Retrieves only active subscriptions for the authenticated user", - }) - @ApiOkResponse({ description: "List of active subscriptions" }) async getActiveSubscriptions(@Request() req: RequestWithUser): Promise { return this.subscriptionsService.getActiveSubscriptions(req.user.id); } @Get("stats") - @ApiOperation({ - summary: "Get subscription statistics", - description: "Retrieves subscription count statistics by status", - }) - @ApiOkResponse({ description: "Subscription statistics" }) async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{ total: number; active: number; @@ -103,13 +70,6 @@ export class SubscriptionsController { } @Get(":id") - @ApiOperation({ - summary: "Get subscription details by ID", - description: "Retrieves detailed information for a specific subscription", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiOkResponse({ description: "Subscription details" }) - @ApiResponse({ status: 404, description: "Subscription not found" }) async getSubscriptionById( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number @@ -122,25 +82,6 @@ export class SubscriptionsController { } @Get(":id/invoices") - @ApiOperation({ - summary: "Get invoices for a specific subscription", - description: "Retrieves all invoices related to a specific subscription", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiQuery({ - name: "page", - required: false, - type: Number, - description: "Page number (default: 1)", - }) - @ApiQuery({ - name: "limit", - required: false, - type: Number, - description: "Items per page (default: 10)", - }) - @ApiOkResponse({ description: "List of invoices for the subscription" }) - @ApiResponse({ status: 404, description: "Subscription not found" }) @UsePipes(new ZodValidationPipe(invoiceListQuerySchema)) async getSubscriptionInvoices( @Request() req: RequestWithUser, @@ -157,12 +98,6 @@ export class SubscriptionsController { // ==================== SIM Management Endpoints ==================== @Get(":id/sim/debug") - @ApiOperation({ - summary: "Debug SIM subscription data", - description: "Retrieves subscription data to help debug SIM management issues", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiResponse({ status: 200, description: "Subscription debug data" }) async debugSimSubscription( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number @@ -171,14 +106,6 @@ export class SubscriptionsController { } @Get(":id/sim") - @ApiOperation({ - summary: "Get SIM details and usage", - description: "Retrieves comprehensive SIM information including details and current usage", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiResponse({ status: 200, description: "SIM information" }) - @ApiResponse({ status: 400, description: "Not a SIM subscription" }) - @ApiResponse({ status: 404, description: "Subscription not found" }) async getSimInfo( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number @@ -187,12 +114,6 @@ export class SubscriptionsController { } @Get(":id/sim/details") - @ApiOperation({ - summary: "Get SIM details", - description: "Retrieves detailed SIM information including ICCID, plan, status, etc.", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiResponse({ status: 200, description: "SIM details" }) async getSimDetails( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number @@ -201,12 +122,6 @@ export class SubscriptionsController { } @Get(":id/sim/usage") - @ApiOperation({ - summary: "Get SIM data usage", - description: "Retrieves current data usage and recent usage history", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiResponse({ status: 200, description: "SIM usage data" }) async getSimUsage( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number @@ -215,14 +130,6 @@ export class SubscriptionsController { } @Get(":id/sim/top-up-history") - @ApiOperation({ - summary: "Get SIM top-up history", - description: "Retrieves data top-up history for the specified date range", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiQuery({ name: "fromDate", description: "Start date (YYYYMMDD)", example: "20240101" }) - @ApiQuery({ name: "toDate", description: "End date (YYYYMMDD)", example: "20241231" }) - @ApiResponse({ status: 200, description: "Top-up history" }) async getSimTopUpHistory( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, @@ -241,22 +148,6 @@ export class SubscriptionsController { @Post(":id/sim/top-up") @UsePipes(new ZodValidationPipe(simTopupRequestSchema)) - @ApiOperation({ - summary: "Top up SIM data quota", - description: "Add data quota to the SIM service", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiBody({ - description: "Top-up request", - schema: { - type: "object", - properties: { - quotaMb: { type: "number", description: "Quota in MB", example: 1000 }, - }, - required: ["quotaMb"], - }, - }) - @ApiResponse({ status: 200, description: "Top-up successful" }) async topUpSim( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, @@ -268,23 +159,6 @@ export class SubscriptionsController { @Post(":id/sim/change-plan") @UsePipes(new ZodValidationPipe(simChangePlanRequestSchema)) - @ApiOperation({ - summary: "Change SIM plan", - description: - "Change the SIM service plan. The change will be automatically scheduled for the 1st of the next month.", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiBody({ - description: "Plan change request", - schema: { - type: "object", - properties: { - newPlanCode: { type: "string", description: "New plan code", example: "LTE3G_P01" }, - }, - required: ["newPlanCode"], - }, - }) - @ApiResponse({ status: 200, description: "Plan change successful" }) async changeSimPlan( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, @@ -300,26 +174,6 @@ export class SubscriptionsController { @Post(":id/sim/cancel") @UsePipes(new ZodValidationPipe(simCancelRequestSchema)) - @ApiOperation({ - summary: "Cancel SIM service", - description: "Cancel the SIM service (immediate or scheduled)", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiBody({ - description: "Cancellation request", - schema: { - type: "object", - properties: { - scheduledAt: { - type: "string", - description: "Schedule cancellation (YYYYMMDD)", - example: "20241231", - }, - }, - }, - required: false, - }) - @ApiResponse({ status: 200, description: "Cancellation successful" }) async cancelSim( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, @@ -330,28 +184,6 @@ export class SubscriptionsController { } @Post(":id/sim/reissue-esim") - @ApiOperation({ - summary: "Reissue eSIM profile", - description: - "Reissue a downloadable eSIM profile (eSIM only). Optionally provide a new EID to transfer to.", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiBody({ - description: "Optional new EID to transfer the eSIM to", - schema: { - type: "object", - properties: { - newEid: { - type: "string", - description: "32-digit EID", - example: "89049032000001000000043598005455", - }, - }, - required: [], - }, - }) - @ApiResponse({ status: 200, description: "eSIM reissue successful" }) - @ApiResponse({ status: 400, description: "Not an eSIM subscription" }) async reissueEsimProfile( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, @@ -363,25 +195,6 @@ export class SubscriptionsController { @Post(":id/sim/features") @UsePipes(new ZodValidationPipe(simFeaturesRequestSchema)) - @ApiOperation({ - summary: "Update SIM features", - description: - "Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)", - }) - @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) - @ApiBody({ - description: "Features update request", - schema: { - type: "object", - properties: { - voiceMailEnabled: { type: "boolean" }, - callWaitingEnabled: { type: "boolean" }, - internationalRoamingEnabled: { type: "boolean" }, - networkType: { type: "string", enum: ["4G", "5G"] }, - }, - }, - }) - @ApiResponse({ status: 200, description: "Features update successful" }) async updateSimFeatures( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, diff --git a/apps/bff/src/modules/users/users.controller.ts b/apps/bff/src/modules/users/users.controller.ts index 52ff879f..67772d12 100644 --- a/apps/bff/src/modules/users/users.controller.ts +++ b/apps/bff/src/modules/users/users.controller.ts @@ -9,7 +9,6 @@ import { UsePipes, } from "@nestjs/common"; import { UsersService } from "./users.service"; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger"; import { ZodValidationPipe } from "@bff/core/validation"; import { updateProfileRequestSchema, @@ -19,43 +18,28 @@ import { } from "@customer-portal/domain"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; -@ApiTags("users") @Controller("me") -@ApiBearerAuth() @UseInterceptors(ClassSerializerInterceptor) export class UsersController { constructor(private usersService: UsersService) {} @Get() - @ApiOperation({ summary: "Get current user profile" }) - @ApiResponse({ status: 200, description: "User profile retrieved successfully" }) - @ApiResponse({ status: 401, description: "Unauthorized" }) async getProfile(@Req() req: RequestWithUser) { return this.usersService.findById(req.user.id); } @Get("summary") - @ApiOperation({ summary: "Get user dashboard summary" }) - @ApiResponse({ status: 200, description: "User summary retrieved successfully" }) - @ApiResponse({ status: 401, description: "Unauthorized" }) async getSummary(@Req() req: RequestWithUser) { return this.usersService.getUserSummary(req.user.id); } @Patch() @UsePipes(new ZodValidationPipe(updateProfileRequestSchema)) - @ApiOperation({ summary: "Update user profile" }) - @ApiResponse({ status: 200, description: "Profile updated successfully" }) - @ApiResponse({ status: 400, description: "Invalid input data" }) - @ApiResponse({ status: 401, description: "Unauthorized" }) async updateProfile(@Req() req: RequestWithUser, @Body() updateData: UpdateProfileRequest) { return this.usersService.update(req.user.id, updateData); } @Get("address") - @ApiOperation({ summary: "Get mailing address" }) - @ApiResponse({ status: 200, description: "Address retrieved successfully" }) - @ApiResponse({ status: 401, description: "Unauthorized" }) async getAddress(@Req() req: RequestWithUser) { return this.usersService.getAddress(req.user.id); } @@ -64,10 +48,6 @@ export class UsersController { @Patch("address") @UsePipes(new ZodValidationPipe(updateAddressRequestSchema)) - @ApiOperation({ summary: "Update mailing address" }) - @ApiResponse({ status: 200, description: "Address updated successfully" }) - @ApiResponse({ status: 400, description: "Invalid input data" }) - @ApiResponse({ status: 401, description: "Unauthorized" }) async updateAddress(@Req() req: RequestWithUser, @Body() address: UpdateAddressRequest) { await this.usersService.updateAddress(req.user.id, address); // Return fresh address snapshot diff --git a/apps/portal/package.json b/apps/portal/package.json index 9880d90e..a782b133 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -29,7 +29,6 @@ "date-fns": "^4.1.0", "lucide-react": "^0.540.0", "next": "15.5.0", - "openapi-fetch": "^0.13.5", "react": "19.1.1", "react-dom": "19.1.1", "react-hook-form": "^7.62.0", diff --git a/apps/portal/src/lib/api/__generated__/types.ts b/apps/portal/src/lib/api/__generated__/types.ts deleted file mode 100644 index debde5e6..00000000 --- a/apps/portal/src/lib/api/__generated__/types.ts +++ /dev/null @@ -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; -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; -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; - }; - }; - }; -} diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index bc4bfcbd..907c4b10 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -1,4 +1,3 @@ -export * as ApiTypes from "./__generated__/types"; export { createClient, resolveBaseUrl } from "./runtime/client"; export type { ApiClient, AuthHeaderResolver, CreateClientOptions } from "./runtime/client"; export { ApiError, isApiError } from "./runtime/client"; diff --git a/apps/portal/src/lib/api/runtime/client.ts b/apps/portal/src/lib/api/runtime/client.ts index 86628ae5..ad24c0a8 100644 --- a/apps/portal/src/lib/api/runtime/client.ts +++ b/apps/portal/src/lib/api/runtime/client.ts @@ -1,7 +1,5 @@ -import createOpenApiClient from "openapi-fetch"; -import type { Middleware, MiddlewareCallbackParams } from "openapi-fetch"; -import type { paths } from "../__generated__/types"; import type { ApiResponse } from "../response-helpers"; + export class ApiError extends Error { constructor( message: string, @@ -15,20 +13,46 @@ export class ApiError extends Error { export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError; -type StrictApiClient = ReturnType>; +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; -type FlexibleApiMethods = { - GET(path: string, options?: unknown): Promise>; - POST(path: string, options?: unknown): Promise>; - PUT(path: string, options?: unknown): Promise>; - PATCH(path: string, options?: unknown): Promise>; - DELETE(path: string, options?: unknown): Promise>; -}; +type PathParams = Record; +type QueryPrimitive = string | number | boolean; +type QueryParams = Record< + string, + QueryPrimitive | QueryPrimitive[] | readonly QueryPrimitive[] | undefined +>; -export type ApiClient = StrictApiClient & FlexibleApiMethods; +export interface RequestOptions { + params?: { + path?: PathParams; + query?: QueryParams; + }; + body?: unknown; + headers?: Record; + signal?: AbortSignal; + credentials?: RequestCredentials; + disableCsrf?: boolean; +} export type AuthHeaderResolver = () => string | undefined; +export interface CreateClientOptions { + baseUrl?: string; + getAuthHeader?: AuthHeaderResolver; + handleError?: (response: Response) => void | Promise; + enableCsrf?: boolean; +} + +type ApiMethod = (path: string, options?: RequestOptions) => Promise>; + +export interface ApiClient { + GET: ApiMethod; + POST: ApiMethod; + PUT: ApiMethod; + PATCH: ApiMethod; + DELETE: ApiMethod; +} + type EnvKey = | "NEXT_PUBLIC_API_BASE" | "NEXT_PUBLIC_API_URL" @@ -56,7 +80,6 @@ const normalizeBaseUrl = (value: string) => { return trimmed; } - // Avoid accidental double slashes when openapi-fetch joins with request path return trimmed.replace(/\/+$/, ""); }; @@ -81,19 +104,56 @@ export const resolveBaseUrl = (baseUrl?: string) => { return resolveBaseUrlFromEnv(); }; -export interface CreateClientOptions { - baseUrl?: string; - getAuthHeader?: AuthHeaderResolver; - handleError?: (response: Response) => void | Promise; - enableCsrf?: boolean; -} +const applyPathParams = (path: string, params?: PathParams): string => { + if (!params) { + return path; + } + + return path.replace(/\{([^}]+)\}/g, (_match, rawKey) => { + const key = rawKey as keyof typeof params; + + if (!(key in params)) { + throw new Error(`Missing path parameter: ${String(rawKey)}`); + } + + const value = params[key]; + return encodeURIComponent(String(value)); + }); +}; + +const buildQueryString = (query?: QueryParams): string => { + if (!query) { + return ""; + } + + const searchParams = new URLSearchParams(); + + const appendPrimitive = (key: string, value: QueryPrimitive) => { + searchParams.append(key, String(value)); + }; + + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + value.forEach(entry => appendPrimitive(key, entry)); + continue; + } + + appendPrimitive(key, value); + } + + return searchParams.toString(); +}; const getBodyMessage = (body: unknown): string | null => { if (typeof body === "string") { return body; } - if (typeof body === "object" && body !== null && "message" in body) { + if (body && typeof body === "object" && "message" in body) { const maybeMessage = (body as { message?: unknown }).message; if (typeof maybeMessage === "string") { return maybeMessage; @@ -132,15 +192,58 @@ async function defaultHandleError(response: Response) { throw new ApiError(message, response, body); } -// CSRF token management +const parseResponseBody = async (response: Response): Promise => { + if (response.status === 204) { + return null; + } + + const contentLength = response.headers.get("content-length"); + if (contentLength === "0") { + return null; + } + + const contentType = response.headers.get("content-type") ?? ""; + + if (contentType.includes("application/json")) { + try { + return await response.json(); + } catch { + return null; + } + } + + if (contentType.includes("text/")) { + try { + return await response.text(); + } catch { + return null; + } + } + + return null; +}; + +interface CsrfTokenPayload { + success: boolean; + token: string; +} + +const isCsrfTokenPayload = (value: unknown): value is CsrfTokenPayload => { + return ( + typeof value === "object" && + value !== null && + "success" in value && + "token" in value && + typeof (value as { success: unknown }).success === "boolean" && + typeof (value as { token: unknown }).token === "string" + ); +}; + class CsrfTokenManager { private token: string | null = null; private tokenPromise: Promise | null = null; - private baseUrl: string; - constructor(baseUrl: string) { - this.baseUrl = baseUrl; - } + constructor(private readonly baseUrl: string) {} async getToken(): Promise { if (this.token) { @@ -160,6 +263,11 @@ class CsrfTokenManager { } } + clearToken(): void { + this.token = null; + this.tokenPromise = null; + } + private async fetchToken(): Promise { const response = await fetch(`${this.baseUrl}/api/security/csrf/token`, { method: "GET", @@ -173,118 +281,111 @@ class CsrfTokenManager { throw new Error(`Failed to fetch CSRF token: ${response.status}`); } - const data = await response.json(); - if (!data.success || !data.token) { + const data: unknown = await response.json(); + if (!isCsrfTokenPayload(data)) { throw new Error("Invalid CSRF token response"); } return data.token; } - - clearToken(): void { - this.token = null; - this.tokenPromise = null; - } - - async refreshToken(): Promise { - this.clearToken(); - return this.getToken(); - } } +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + export function createClient(options: CreateClientOptions = {}): ApiClient { const baseUrl = resolveBaseUrl(options.baseUrl); - const client = createOpenApiClient({ baseUrl }); - + const resolveAuthHeader = options.getAuthHeader; const handleError = options.handleError ?? defaultHandleError; const enableCsrf = options.enableCsrf ?? true; const csrfManager = enableCsrf ? new CsrfTokenManager(baseUrl) : null; - if (typeof client.use === "function") { - const resolveAuthHeader = options.getAuthHeader; + const request = async ( + method: HttpMethod, + path: string, + opts: RequestOptions = {} + ): Promise> => { + const resolvedPath = applyPathParams(path, opts.params?.path); + const url = new URL(resolvedPath, baseUrl); - const middleware: Middleware = { - async onRequest({ request }: MiddlewareCallbackParams) { - if (!request) return; + const queryString = buildQueryString(opts.params?.query); + if (queryString) { + url.search = queryString; + } - const nextRequest = new Request(request, { - credentials: "include", - }); + const headers = new Headers(opts.headers); - // Add CSRF token for non-safe methods - if (csrfManager && ["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) { - try { - const csrfToken = await csrfManager.getToken(); - nextRequest.headers.set("X-CSRF-Token", csrfToken); - } catch (error) { - console.warn("Failed to get CSRF token:", error); - // Continue without CSRF token - let the server handle the error - } - } - - // Add auth header if available - if (resolveAuthHeader && typeof nextRequest.headers?.has === "function") { - if (!nextRequest.headers.has("Authorization")) { - const headerValue = resolveAuthHeader(); - if (headerValue) { - nextRequest.headers.set("Authorization", headerValue); - } - } - } - - return nextRequest; - }, - async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) { - // Handle CSRF token refresh on 403 errors - if (response.status === 403 && csrfManager) { - try { - const errorText = await response.clone().text(); - if (errorText.includes("CSRF") || errorText.includes("csrf")) { - // Clear the token so next request will fetch a new one - csrfManager.clearToken(); - } - } catch { - // Ignore errors when checking response body - } - } - - await handleError(response); - }, + const credentials = opts.credentials ?? "include"; + const init: RequestInit = { + method, + headers, + credentials, + signal: opts.signal, }; - client.use(middleware as never); - } + const body = opts.body; + if (body !== undefined && body !== null) { + if (body instanceof FormData || body instanceof Blob) { + init.body = body as BodyInit; + } else { + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + init.body = JSON.stringify(body); + } + } - const flexibleClient = client as ApiClient; + if (resolveAuthHeader && !headers.has("Authorization")) { + const headerValue = resolveAuthHeader(); + if (headerValue) { + headers.set("Authorization", headerValue); + } + } - // Store references to original methods before overriding - const originalGET = client.GET.bind(client); - const originalPOST = client.POST.bind(client); - const originalPUT = client.PUT.bind(client); - const originalPATCH = client.PATCH.bind(client); - const originalDELETE = client.DELETE.bind(client); + if ( + csrfManager && + !opts.disableCsrf && + !SAFE_METHODS.has(method) && + !headers.has("X-CSRF-Token") + ) { + try { + const csrfToken = await csrfManager.getToken(); + headers.set("X-CSRF-Token", csrfToken); + } catch (error) { + console.warn("Failed to obtain CSRF token", error); + } + } - flexibleClient.GET = (async (path: string, options?: unknown) => { - return (originalGET as FlexibleApiMethods["GET"])(path, options); - }) as ApiClient["GET"]; + const response = await fetch(url.toString(), init); - flexibleClient.POST = (async (path: string, options?: unknown) => { - return (originalPOST as FlexibleApiMethods["POST"])(path, options); - }) as ApiClient["POST"]; + if (!response.ok) { + if (response.status === 403 && csrfManager) { + try { + const bodyText = await response.clone().text(); + if (bodyText.toLowerCase().includes("csrf")) { + csrfManager.clearToken(); + } + } catch { + csrfManager.clearToken(); + } + } - flexibleClient.PUT = (async (path: string, options?: unknown) => { - return (originalPUT as FlexibleApiMethods["PUT"])(path, options); - }) as ApiClient["PUT"]; + await handleError(response); + // If handleError does not throw, throw a default error to ensure rejection + throw new ApiError(`Request failed with status ${response.status}`, response); + } - flexibleClient.PATCH = (async (path: string, options?: unknown) => { - return (originalPATCH as FlexibleApiMethods["PATCH"])(path, options); - }) as ApiClient["PATCH"]; + const parsedBody = await parseResponseBody(response); - flexibleClient.DELETE = (async (path: string, options?: unknown) => { - return (originalDELETE as FlexibleApiMethods["DELETE"])(path, options); - }) as ApiClient["DELETE"]; + return { + data: (parsedBody as T | null | undefined) ?? null, + }; + }; - return flexibleClient; + return { + GET: (path, opts) => request("GET", path, opts), + POST: (path, opts) => request("POST", path, opts), + PUT: (path, opts) => request("PUT", path, opts), + PATCH: (path, opts) => request("PATCH", path, opts), + DELETE: (path, opts) => request("DELETE", path, opts), + } satisfies ApiClient; } - -export type { paths }; diff --git a/apps/portal/src/lib/api/types.ts b/apps/portal/src/lib/api/types.ts index c287f207..692b647c 100644 --- a/apps/portal/src/lib/api/types.ts +++ b/apps/portal/src/lib/api/types.ts @@ -3,9 +3,6 @@ * Re-exports and additional types for the API client */ -// Re-export generated types -export * from "./__generated__/types"; - // Additional query parameter types export interface InvoiceQueryParams { page?: number; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 14af0c23..e0dab2ab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -45,7 +45,7 @@ src/ templates/ # Page layouts features/ # Feature modules (auth, billing, etc.) lib/ # Core utilities and services - api/ # OpenAPI client with type generation + api/ # Zod-aware fetch client + helpers hooks/ # Shared React hooks utils/ # Utility functions providers/ # Context providers @@ -101,8 +101,8 @@ src/ ## 🔗 **Integration Architecture** ### **API Client** -- **Implementation**: OpenAPI-based with `openapi-fetch` -- **Features**: Automatic type generation, CSRF protection, auth handling +- **Implementation**: Fetch wrapper using shared Zod schemas from `@customer-portal/domain` +- **Features**: CSRF protection, auth handling, consistent `ApiResponse` helpers - **Location**: `apps/portal/src/lib/api/` ### **External Services** diff --git a/package.json b/package.json index c02ad313..55f3b803 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "update:safe": "pnpm update --recursive && pnpm audit && pnpm type-check", "dev:watch": "pnpm --parallel --filter @customer-portal/domain --filter @customer-portal/portal --filter @customer-portal/bff run dev", "plesk:images": "bash ./scripts/plesk/build-images.sh", - "openapi:gen": "pnpm --filter @customer-portal/bff run openapi:gen", "types:gen": "./scripts/generate-frontend-types.sh", "codegen": "pnpm types:gen", "postinstall": "pnpm codegen || true" diff --git a/packages/domain/src/validation/api/requests.ts b/packages/domain/src/validation/api/requests.ts index ed23f60d..2118456a 100644 --- a/packages/domain/src/validation/api/requests.ts +++ b/packages/domain/src/validation/api/requests.ts @@ -38,6 +38,12 @@ export const auditLogQuerySchema = paginationQuerySchema.extend({ export type AuditLogQuery = z.infer; +export const dryRunQuerySchema = z.object({ + dryRun: z.coerce.boolean().optional(), +}); + +export type DryRunQuery = z.infer; + export const invoiceListQuerySchema = paginationQuerySchema.extend({ status: invoiceStatusEnum.optional(), }); @@ -299,6 +305,18 @@ export type Invoice = z.infer; export type Pagination = z.infer; export type InvoiceList = z.infer; +export const invoicePaymentLinkSchema = z.object({ + paymentMethodId: z.coerce.number().int().positive().optional(), + gatewayName: z.string().min(1).optional(), +}); + +export type InvoicePaymentLinkInput = z.infer; + +export const sfOrderIdParamSchema = z.object({ + sfOrderId: z.string().min(1, "Salesforce order ID is required"), +}); + +export type SfOrderIdParam = z.infer; // ===================================================== // ID MAPPING SCHEMAS // ===================================================== diff --git a/packages/domain/src/validation/index.ts b/packages/domain/src/validation/index.ts index ddc7bc4d..317a041e 100644 --- a/packages/domain/src/validation/index.ts +++ b/packages/domain/src/validation/index.ts @@ -62,6 +62,8 @@ export { invoiceListQuerySchema, paginationQuerySchema, subscriptionQuerySchema, + invoicePaymentLinkSchema, + sfOrderIdParamSchema, // API types type LoginRequestInput, @@ -88,6 +90,8 @@ export { type InvoiceListQuery, type PaginationQuery, type SubscriptionQuery, + type InvoicePaymentLinkInput, + type SfOrderIdParam, } from "./api/requests"; // Form schemas (frontend) - explicit exports for better tree shaking diff --git a/scripts/generate-frontend-types.sh b/scripts/generate-frontend-types.sh index 705f8fd1..e101907d 100755 --- a/scripts/generate-frontend-types.sh +++ b/scripts/generate-frontend-types.sh @@ -1,16 +1,8 @@ #!/bin/bash -# 🎯 Automated Frontend Type Generation -# This script ensures frontend types are always in sync with backend OpenAPI spec +# Zod-based frontend type support +# OpenAPI generation has been removed; shared schemas live in @customer-portal/domain. set -e -echo "🔄 Generating OpenAPI spec from backend..." -cd "$(dirname "$0")/.." -pnpm openapi:gen - -echo "🔄 Generating frontend types from OpenAPI spec..." -npx openapi-typescript apps/bff/openapi/openapi.json -o apps/portal/src/lib/api/__generated__/types.ts - -echo "✅ Frontend types updated successfully!" -echo "📝 Updated: apps/portal/src/lib/api/__generated__/types.ts" +echo "ℹ️ Skipping OpenAPI generation: frontend consumes shared Zod schemas from @customer-portal/domain."