diff --git a/README.md b/README.md index d9b0ad5a..30a413c1 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,13 @@ A modern customer portal where users can self-register, log in, browse & buy sub - **BullMQ** for async jobs with ioredis - **OpenAPI/Swagger** for documentation +### Temporarily Disabled Modules + +- `CasesModule` and `JobsModule` are intentionally excluded from the running + NestJS application until their APIs and job processors are fully + implemented. See `docs/TEMPORARY-DISABLED-MODULES.md` for re-enablement + details and placeholder behaviour. + ### Logging - Centralized structured logging via Pino using `nestjs-pino` in the BFF diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 9ddf5a65..dc49e871 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -21,7 +21,6 @@ import { EmailModule } from "@bff/infra/email/email.module"; // External Integration Modules import { IntegrationsModule } from "@bff/integrations/integrations.module"; import { SalesforceEventsModule } from "@bff/integrations/salesforce/events/events.module"; -import { JobsModule } from "@bff/modules/jobs/jobs.module"; // Feature Modules import { AuthModule } from "@bff/modules/auth/auth.module"; @@ -31,7 +30,6 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module"; import { OrdersModule } from "@bff/modules/orders/orders.module"; import { InvoicesModule } from "@bff/modules/invoices/invoices.module"; import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module"; -import { CasesModule } from "@bff/modules/cases/cases.module"; // System Modules import { HealthModule } from "@bff/modules/health/health.module"; @@ -73,7 +71,6 @@ import { SuccessResponseInterceptor } from "@bff/core/http/success-response.inte // === EXTERNAL INTEGRATIONS === IntegrationsModule, SalesforceEventsModule, - JobsModule, // === FEATURE MODULES === AuthModule, @@ -83,7 +80,6 @@ import { SuccessResponseInterceptor } from "@bff/core/http/success-response.inte OrdersModule, InvoicesModule, SubscriptionsModule, - CasesModule, // === SYSTEM MODULES === HealthModule, diff --git a/apps/bff/src/app/bootstrap.ts b/apps/bff/src/app/bootstrap.ts new file mode 100644 index 00000000..0f08c4ee --- /dev/null +++ b/apps/bff/src/app/bootstrap.ts @@ -0,0 +1,152 @@ +import { type INestApplication, ValidationPipe } 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"; +import * as express from "express"; + +import { GlobalExceptionFilter } from "@bff/core/http/http-exception.filter"; + +import { AppModule } from "../app.module"; + +export async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule, { + bufferLogs: true, + // bodyParser is enabled by default in NestJS + rawBody: true, // Enable raw body access for debugging + }); + + // Set Pino as the logger + app.useLogger(app.get(Logger)); + + const configService = app.get(ConfigService); + const logger = app.get(Logger); + + // Enhanced Security Headers + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:"], + connectSrc: ["'self'"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + mediaSrc: ["'self'"], + frameSrc: ["'none'"], + }, + }, + crossOriginEmbedderPolicy: false, + crossOriginResourcePolicy: { policy: "cross-origin" }, + }) + ); + + // Disable x-powered-by header + const expressInstance = app.getHttpAdapter().getInstance() as { + disable?: (name: string) => void; + }; + if (typeof expressInstance?.disable === "function") { + expressInstance.disable("x-powered-by"); + } + + // Configure JSON body parser with proper limits + app.use(express.json({ limit: "10mb" })); + app.use(express.urlencoded({ extended: true, limit: "10mb" })); + + // Enhanced cookie parser with security options + app.use(cookieParser()); + + // Trust proxy configuration for reverse proxies + if (configService.get("TRUST_PROXY", "false") === "true") { + const httpAdapter = app.getHttpAdapter(); + const instance = httpAdapter.getInstance() as { + set?: (key: string, value: unknown) => void; + }; + if (typeof instance?.set === "function") { + instance.set("trust proxy", 1); + } + } + + // Enhanced CORS configuration + const corsOrigin = configService.get("CORS_ORIGIN"); + app.enableCors({ + origin: corsOrigin ? [corsOrigin] : false, + credentials: true, + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Origin", + "X-Requested-With", + "Content-Type", + "Accept", + "Authorization", + "X-API-Key", + ], + exposedHeaders: ["X-Total-Count", "X-Page-Count"], + maxAge: 86400, // 24 hours + }); + + // Global validation pipe with enhanced security + const exposeValidation = configService.get("EXPOSE_VALIDATION_ERRORS", "false") === "true"; + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + forbidUnknownValues: true, + disableErrorMessages: !exposeValidation && configService.get("NODE_ENV") === "production", + validationError: { + target: false, + value: false, + }, + }) + ); + + // Global exception filter + app.useGlobalFilters(new GlobalExceptionFilter(app.get(Logger))); + + // Global authentication guard will be registered via APP_GUARD provider in AuthModule + + // 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)); + + await app.listen(port, "0.0.0.0"); + + // Enhanced startup information + logger.log(`🚀 BFF API running on: http://localhost:${port}/api`); + logger.log(`🌐 Frontend Portal: http://localhost:${configService.get("NEXT_PORT", 3000)}`); + logger.log( + `🗄️ Database: ${configService.get("DATABASE_URL", "postgresql://dev:dev@localhost:5432/portal_dev")}` + ); + logger.log(`🔗 Prisma Studio: http://localhost:5555`); + logger.log(`🔴 Redis: ${configService.get("REDIS_URL", "redis://localhost:6379")}`); + + if (configService.get("NODE_ENV") !== "production") { + logger.log(`📚 API Documentation: http://localhost:${port}/docs`); + } + + return app; +} diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts index 3838d88e..37c97a4c 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -6,7 +6,6 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module"; import { OrdersModule } from "@bff/modules/orders/orders.module"; import { InvoicesModule } from "@bff/modules/invoices/invoices.module"; import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module"; -import { CasesModule } from "@bff/modules/cases/cases.module"; export const apiRoutes: Routes = [ { @@ -19,7 +18,6 @@ export const apiRoutes: Routes = [ { path: "", module: OrdersModule }, { path: "", module: InvoicesModule }, { path: "", module: SubscriptionsModule }, - { path: "", module: CasesModule }, ], }, ]; diff --git a/apps/bff/src/main.ts b/apps/bff/src/main.ts index 2b2d8f81..47d24966 100644 --- a/apps/bff/src/main.ts +++ b/apps/bff/src/main.ts @@ -1,151 +1,45 @@ -import { NestFactory } from "@nestjs/core"; -import { ValidationPipe } from "@nestjs/common"; -import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; -import { ConfigService } from "@nestjs/config"; -import { Logger } from "nestjs-pino"; -import helmet from "helmet"; -import cookieParser from "cookie-parser"; -import * as express from "express"; +import { Logger, type INestApplication } from "@nestjs/common"; -import { AppModule } from "./app.module"; -import { GlobalExceptionFilter } from "@bff/core/http/http-exception.filter"; +import { bootstrap } from "./app/bootstrap"; -async function bootstrap() { - const app = await NestFactory.create(AppModule, { - bufferLogs: true, - // bodyParser is enabled by default in NestJS - rawBody: true, // Enable raw body access for debugging - }); +const logger = new Logger("Main"); +let app: INestApplication | null = null; - // Set Pino as the logger - app.useLogger(app.get(Logger)); +const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; +for (const signal of signals) { + process.once(signal, async () => { + logger.log(`Received ${signal}. Closing Nest application...`); - const configService = app.get(ConfigService); - const logger = app.get(Logger); - - // Enhanced Security Headers - app.use( - helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], - scriptSrc: ["'self'"], - imgSrc: ["'self'", "data:", "https:"], - connectSrc: ["'self'"], - fontSrc: ["'self'"], - objectSrc: ["'none'"], - mediaSrc: ["'self'"], - frameSrc: ["'none'"], - }, - }, - crossOriginEmbedderPolicy: false, - crossOriginResourcePolicy: { policy: "cross-origin" }, - }) - ); - - // Disable x-powered-by header - const expressInstance = app.getHttpAdapter().getInstance() as { - disable?: (name: string) => void; - }; - if (typeof expressInstance?.disable === "function") { - expressInstance.disable("x-powered-by"); - } - - // Configure JSON body parser with proper limits - app.use(express.json({ limit: "10mb" })); - app.use(express.urlencoded({ extended: true, limit: "10mb" })); - - // Enhanced cookie parser with security options - app.use(cookieParser()); - - // Trust proxy configuration for reverse proxies - if (configService.get("TRUST_PROXY", "false") === "true") { - const httpAdapter = app.getHttpAdapter(); - const instance = httpAdapter.getInstance() as { - set?: (key: string, value: unknown) => void; - }; - if (typeof instance?.set === "function") { - instance.set("trust proxy", 1); + if (!app) { + logger.warn("Nest application not initialized. Exiting immediately."); + process.exit(0); + return; } - } - // Enhanced CORS configuration - const corsOrigin = configService.get("CORS_ORIGIN"); - app.enableCors({ - origin: corsOrigin ? [corsOrigin] : false, - credentials: true, - methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allowedHeaders: [ - "Origin", - "X-Requested-With", - "Content-Type", - "Accept", - "Authorization", - "X-API-Key", - ], - exposedHeaders: ["X-Total-Count", "X-Page-Count"], - maxAge: 86400, // 24 hours + try { + await app.close(); + logger.log("Nest application closed gracefully."); + } catch (error) { + const resolvedError = error as Error; + logger.error( + `Error during Nest application shutdown: ${resolvedError.message}`, + resolvedError.stack + ); + } finally { + process.exit(0); + } }); - - // Global validation pipe with enhanced security - const exposeValidation = configService.get("EXPOSE_VALIDATION_ERRORS", "false") === "true"; - app.useGlobalPipes( - new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - disableErrorMessages: !exposeValidation && configService.get("NODE_ENV") === "production", - validationError: { - target: false, - value: false, - }, - }) - ); - - // Global exception filter - app.useGlobalFilters(new GlobalExceptionFilter(app.get(Logger))); - - // Global authentication guard will be registered via APP_GUARD provider in AuthModule - - // 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)); - - await app.listen(port, "0.0.0.0"); - - // Enhanced startup information - logger.log(`🚀 BFF API running on: http://localhost:${port}/api`); - logger.log(`🌐 Frontend Portal: http://localhost:${configService.get("NEXT_PORT", 3000)}`); - logger.log( - `🗄️ Database: ${configService.get("DATABASE_URL", "postgresql://dev:dev@localhost:5432/portal_dev")}` - ); - logger.log(`🔗 Prisma Studio: http://localhost:5555`); - logger.log(`🔴 Redis: ${configService.get("REDIS_URL", "redis://localhost:6379")}`); - - if (configService.get("NODE_ENV") !== "production") { - logger.log(`📚 API Documentation: http://localhost:${port}/docs`); - } } -void bootstrap(); +void bootstrap() + .then((startedApp) => { + app = startedApp; + }) + .catch((error) => { + const resolvedError = error as Error; + logger.error( + `Failed to bootstrap the Nest application: ${resolvedError.message}`, + resolvedError.stack + ); + process.exit(1); + }); diff --git a/apps/bff/src/modules/jobs/reconcile.processor.ts b/apps/bff/src/modules/jobs/reconcile.processor.ts index 9cf45c28..49e85b74 100644 --- a/apps/bff/src/modules/jobs/reconcile.processor.ts +++ b/apps/bff/src/modules/jobs/reconcile.processor.ts @@ -1,11 +1,22 @@ import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; import { Job } from "bullmq"; import { QUEUE_NAMES } from "@bff/infra/queue/queue.constants"; @Processor(QUEUE_NAMES.RECONCILE) export class ReconcileProcessor extends WorkerHost { - async process(_job: Job) { - // TODO: Implement reconciliation logic - // Note: In production, this should use proper logging + private readonly logger = new Logger(ReconcileProcessor.name); + + async process(job: Job) { + this.logger.warn( + `Skipping reconciliation job while JobsModule is temporarily disabled`, + { + jobId: job.id, + name: job.name, + attemptsMade: job.attemptsMade, + }, + ); + + return { status: "skipped", reason: "jobs_module_disabled" }; } } diff --git a/apps/portal/src/core/api/client.ts b/apps/portal/src/core/api/client.ts index b1d846c6..0ebc92b0 100644 --- a/apps/portal/src/core/api/client.ts +++ b/apps/portal/src/core/api/client.ts @@ -1,4 +1,4 @@ -/** + * Core API client configuration * Wraps the shared generated client to inject portal-specific behavior like auth headers. */ @@ -24,4 +24,4 @@ export const configureApiClientAuth = (resolver?: AuthHeaderResolver) => { authHeaderResolver = resolver; }; -export type ApiClient = GeneratedApiClient; +export type ApiClient = GeneratedApiClient; \ No newline at end of file diff --git a/apps/portal/src/core/api/index.ts b/apps/portal/src/core/api/index.ts index 75bf298e..6289dcd7 100644 --- a/apps/portal/src/core/api/index.ts +++ b/apps/portal/src/core/api/index.ts @@ -1,4 +1,5 @@ export { apiClient, configureApiClientAuth } from "./client"; export { queryKeys } from "./query-keys"; + export type { ApiClient } from "./client"; export type { AuthHeaderResolver } from "@customer-portal/api-client"; diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index bbbb01d4..3fe281a0 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -352,6 +352,7 @@ export const useAuthStore = create()( error: null, hasCheckedAuth: true, }); + } } catch (error) { // Token is invalid, clear auth state diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 7c84dc5a..807d920b 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -22,9 +22,11 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { return useQuery({ queryKey: ["subscriptions", status], queryFn: async () => { - if (!hasValidToken) { - throw new Error("Authentication required"); - } + + const params = new URLSearchParams({ + ...(status && { status }), + }); + const response = await apiClient.GET( "/subscriptions", @@ -56,6 +58,7 @@ export function useActiveSubscriptions() { throw new Error("Authentication required"); } + const response = await apiClient.GET("/subscriptions/active"); return (response.data ?? []) as Subscription[]; }, @@ -84,6 +87,7 @@ export function useSubscriptionStats() { throw new Error("Authentication required"); } + const response = await apiClient.GET("/subscriptions/stats"); return (response.data ?? { total: 0, @@ -92,12 +96,16 @@ export function useSubscriptionStats() { cancelled: 0, pending: 0, }) as { + + total: number; active: number; suspended: number; cancelled: number; pending: number; + }; + }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes @@ -118,6 +126,7 @@ export function useSubscription(subscriptionId: number) { throw new Error("Authentication required"); } + const response = await apiClient.GET("/subscriptions/{id}", { params: { path: { id: subscriptionId } }, }); @@ -127,6 +136,7 @@ export function useSubscription(subscriptionId: number) { } return response.data as Subscription; + }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes @@ -168,6 +178,7 @@ export function useSubscriptionInvoices( }, } ) as InvoiceList; + }, staleTime: 60 * 1000, // 1 minute gcTime: 5 * 60 * 1000, // 5 minutes @@ -187,6 +198,7 @@ export function useSubscriptionAction() { params: { path: { id } }, body: { action }, }); + }, onSuccess: (_, { id }) => { // Invalidate relevant queries after successful action diff --git a/docs/TEMPORARY-DISABLED-MODULES.md b/docs/TEMPORARY-DISABLED-MODULES.md new file mode 100644 index 00000000..3d8aedd8 --- /dev/null +++ b/docs/TEMPORARY-DISABLED-MODULES.md @@ -0,0 +1,28 @@ +# Temporarily Disabled Modules + +The backend currently omits two partially implemented modules from the runtime +NestJS configuration so that the public API surface only exposes completed +features. + +## Cases Module + +- Removed from `AppModule` and `apiRoutes` to ensure the unfinished `/cases` + endpoints are not routable. +- All existing code remains in `apps/bff/src/modules/cases/` for future + development; re-enable by importing the module in + `apps/bff/src/app.module.ts` and adding it back to the router configuration in + `apps/bff/src/core/config/router.config.ts` once the endpoints are ready. + +## Jobs Module + +- Temporarily excluded from `AppModule` while the reconciliation workflows are + fleshed out. +- The BullMQ processor now logs an explicit warning and acknowledges each job so + queue workers do not hang when the module is re-registered. +- When background processing is ready, restore the `JobsModule` import in + `apps/bff/src/app.module.ts` and replace the placeholder logic in + `ReconcileProcessor.process` with the real reconciliation implementation. + +> **Note**: If additional queues or HTTP routes reference these modules, make +> sure they fail fast with a `501 Not Implemented` response or similar logging so +> that downstream systems have clear telemetry while the modules are disabled. diff --git a/packages/api-client/src/runtime/client.ts b/packages/api-client/src/runtime/client.ts index cf638b1a..21f33a1c 100644 --- a/packages/api-client/src/runtime/client.ts +++ b/packages/api-client/src/runtime/client.ts @@ -18,6 +18,7 @@ export function createClient( throwOnError: true, }); + if (typeof client.use === "function" && options.getAuthHeader) { const resolveAuthHeader = options.getAuthHeader;