Merge branch 'ver2' into codex/extend-api-client-for-auth-header-os9pej

This commit is contained in:
NTumurbars 2025-09-18 16:38:32 +09:00 committed by GitHub
commit a9f7aa5403
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 257 additions and 156 deletions

View File

@ -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

View File

@ -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,

View File

@ -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<INestApplication> {
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<string | undefined>("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;
}

View File

@ -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 },
],
},
];

View File

@ -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;
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
for (const signal of signals) {
process.once(signal, async () => {
logger.log(`Received ${signal}. Closing Nest application...`);
if (!app) {
logger.warn("Nest application not initialized. Exiting immediately.");
process.exit(0);
return;
}
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);
}
});
}
// 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" },
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
);
// 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<string | undefined>("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
process.exit(1);
});
// 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();

View File

@ -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" };
}
}

View File

@ -1,4 +1,4 @@
/**
* Core API client configuration
* Wraps the shared generated client to inject portal-specific behavior like auth headers.
*/

View File

@ -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";

View File

@ -352,6 +352,7 @@ export const useAuthStore = create<AuthStoreState>()(
error: null,
hasCheckedAuth: true,
});
}
} catch (error) {
// Token is invalid, clear auth state

View File

@ -22,9 +22,11 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
return useQuery<SubscriptionList | Subscription[]>({
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

View File

@ -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.

View File

@ -18,6 +18,7 @@ export function createClient(
throwOnError: true,
});
if (typeof client.use === "function" && options.getAuthHeader) {
const resolveAuthHeader = options.getAuthHeader;