From 01e0f41c146327e3c8c7d68671a127216cf61265 Mon Sep 17 00:00:00 2001 From: NTumurbars <156628271+NTumurbars@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:18:12 +0900 Subject: [PATCH 1/3] refactor: extract bff bootstrap logic --- apps/bff/src/app/bootstrap.ts | 152 +++++++++++++++++++++++++++++ apps/bff/src/main.ts | 178 +++++++--------------------------- 2 files changed, 188 insertions(+), 142 deletions(-) create mode 100644 apps/bff/src/app/bootstrap.ts 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/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); + }); From d34e14019343e505e80f6a2b5d4cb583ab34dbdb Mon Sep 17 00:00:00 2001 From: NTumurbars <156628271+NTumurbars@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:18:44 +0900 Subject: [PATCH 2/3] chore: temporarily disable cases and jobs modules --- README.md | 7 +++++ apps/bff/src/app.module.ts | 4 --- apps/bff/src/core/config/router.config.ts | 2 -- .../src/modules/jobs/reconcile.processor.ts | 17 +++++++++-- docs/TEMPORARY-DISABLED-MODULES.md | 28 +++++++++++++++++++ 5 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 docs/TEMPORARY-DISABLED-MODULES.md 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/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/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/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. From 3242f49ce72d688906fe0de8752fc676ae3be346 Mon Sep 17 00:00:00 2001 From: NTumurbars <156628271+NTumurbars@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:00:20 +0900 Subject: [PATCH 3/3] refactor: simplify api client auth integration --- apps/portal/src/core/api/client.ts | 173 +++++++++++++++++- apps/portal/src/core/api/index.ts | 4 +- .../features/account/hooks/useProfileData.ts | 19 +- .../account/services/account.service.ts | 37 ++++ .../src/features/auth/services/auth.store.ts | 149 ++++++++++++--- .../subscriptions/hooks/useSubscriptions.ts | 65 +++---- packages/api-client/src/index.ts | 9 +- packages/api-client/src/runtime/client.ts | 42 ++++- 8 files changed, 401 insertions(+), 97 deletions(-) create mode 100644 apps/portal/src/features/account/services/account.service.ts diff --git a/apps/portal/src/core/api/client.ts b/apps/portal/src/core/api/client.ts index 946a24da..02ef48c8 100644 --- a/apps/portal/src/core/api/client.ts +++ b/apps/portal/src/core/api/client.ts @@ -1,13 +1,174 @@ /** * Core API Client - * Minimal API client setup using the generated OpenAPI client + * Instantiates the shared OpenAPI client with portal-specific configuration. */ -import { createClient } from "@customer-portal/api-client"; +import { createClient as createOpenApiClient } from "@customer-portal/api-client"; +import type { ApiClient as GeneratedApiClient } from "@customer-portal/api-client"; import { env } from "../config/env"; -// Create the type-safe API client -export const apiClient = createClient(env.NEXT_PUBLIC_API_BASE); +const DEFAULT_JSON_CONTENT_TYPE = "application/json"; -// Export the client type for use in hooks -export type { ApiClient } from "@customer-portal/api-client"; +export type AuthHeaderGetter = () => string | undefined; + +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export type ApiRequestOptions = Omit & { + body?: unknown; + headers?: HeadersInit; +}; + +export interface HttpError extends Error { + status: number; + data?: unknown; +} + +type RestMethods = { + request: (method: HttpMethod, path: string, options?: ApiRequestOptions) => Promise; + get: (path: string, options?: Omit) => Promise; + post: ( + path: string, + body?: B, + options?: Omit + ) => Promise; + put: ( + path: string, + body?: B, + options?: Omit + ) => Promise; + patch: ( + path: string, + body?: B, + options?: Omit + ) => Promise; + delete: ( + path: string, + options?: Omit & { body?: B } + ) => Promise; +}; + +export type ApiClient = GeneratedApiClient & RestMethods; + +const baseUrl = env.NEXT_PUBLIC_API_BASE; + +let authHeaderGetter: AuthHeaderGetter | undefined; + +const resolveAuthHeader = () => authHeaderGetter?.(); + +const joinUrl = (base: string, path: string) => { + if (/^https?:\/\//.test(path)) { + return path; + } + + const trimmedBase = base.endsWith("/") ? base.slice(0, -1) : base; + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `${trimmedBase}${normalizedPath}`; +}; + +const applyAuthHeader = (headersInit?: HeadersInit) => { + const headers = + headersInit instanceof Headers ? headersInit : new Headers(headersInit ?? undefined); + + const headerValue = resolveAuthHeader(); + + if (headerValue && !headers.has("Authorization")) { + headers.set("Authorization", headerValue); + } + + return headers; +}; + +const serializeBody = (body: unknown, headers: Headers): BodyInit | undefined => { + if (body === undefined || body === null) { + return undefined; + } + + if ( + body instanceof FormData || + body instanceof Blob || + body instanceof URLSearchParams || + typeof body === "string" + ) { + return body; + } + + if (!headers.has("Content-Type")) { + headers.set("Content-Type", DEFAULT_JSON_CONTENT_TYPE); + } + + return JSON.stringify(body); +}; + +const parseResponseBody = async (response: Response) => { + if (response.status === 204) { + return undefined; + } + + const text = await response.text(); + if (!text) { + return undefined; + } + + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +}; + +const createHttpError = (response: Response, data: unknown): HttpError => { + const message = + typeof data === "object" && data !== null && "message" in data && + typeof (data as Record).message === "string" + ? (data as { message: string }).message + : `Request failed with status ${response.status}`; + + const error = new Error(message) as HttpError; + error.status = response.status; + if (data !== undefined) { + error.data = data; + } + return error; +}; + +const request = async ( + method: HttpMethod, + path: string, + options: ApiRequestOptions = {} +): Promise => { + const { body, headers: headersInit, ...rest } = options; + const headers = applyAuthHeader(headersInit); + const serializedBody = serializeBody(body, headers); + const response = await fetch(joinUrl(baseUrl, path), { + ...rest, + method, + headers, + body: serializedBody, + }); + + const parsedBody = await parseResponseBody(response); + if (!response.ok) { + throw createHttpError(response, parsedBody); + } + + return parsedBody as T; +}; + +const restMethods: RestMethods = { + request, + get: (path, options) => request("GET", path, options as ApiRequestOptions), + post: (path, body, options) => request("POST", path, { ...options, body }), + put: (path, body, options) => request("PUT", path, { ...options, body }), + patch: (path, body, options) => request("PATCH", path, { ...options, body }), + delete: (path, options) => request("DELETE", path, options as ApiRequestOptions), +}; + +const openApiClient = createOpenApiClient(baseUrl, { + getAuthHeader: resolveAuthHeader, +}); + +export const apiClient = Object.assign(openApiClient, restMethods) as ApiClient; + +export const configureApiClientAuth = (getter?: AuthHeaderGetter) => { + authHeaderGetter = getter; +}; diff --git a/apps/portal/src/core/api/index.ts b/apps/portal/src/core/api/index.ts index e3689269..e3d9e55d 100644 --- a/apps/portal/src/core/api/index.ts +++ b/apps/portal/src/core/api/index.ts @@ -1,3 +1,3 @@ -export { apiClient } from "./client"; +export { apiClient, configureApiClientAuth } from "./client"; export { queryKeys } from "./query-keys"; -export type { ApiClient } from "./client"; +export type { ApiClient, ApiRequestOptions, AuthHeaderGetter, HttpError } from "./client"; diff --git a/apps/portal/src/features/account/hooks/useProfileData.ts b/apps/portal/src/features/account/hooks/useProfileData.ts index dcf9e216..4623182e 100644 --- a/apps/portal/src/features/account/hooks/useProfileData.ts +++ b/apps/portal/src/features/account/hooks/useProfileData.ts @@ -85,22 +85,11 @@ export function useProfileData() { const saveProfile = async (next: ProfileFormData) => { setIsSavingProfile(true); try { - const { tokens } = useAuthStore.getState(); - if (!tokens?.accessToken) throw new Error("Authentication required"); - const response = await fetch("/api/me", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${tokens.accessToken}`, - }, - body: JSON.stringify({ - firstName: next.firstName, - lastName: next.lastName, - phone: next.phone, - }), + const updatedUser = await accountService.updateProfile({ + firstName: next.firstName, + lastName: next.lastName, + phone: next.phone, }); - if (!response.ok) throw new Error("Failed to update profile"); - const updatedUser = (await response.json()) as Partial; useAuthStore.setState(state => ({ ...state, user: state.user ? { ...state.user, ...updatedUser } : state.user, diff --git a/apps/portal/src/features/account/services/account.service.ts b/apps/portal/src/features/account/services/account.service.ts new file mode 100644 index 00000000..c5ddbce4 --- /dev/null +++ b/apps/portal/src/features/account/services/account.service.ts @@ -0,0 +1,37 @@ +import { apiClient } from "@/core/api"; +import type { Address, AuthUser } from "@customer-portal/domain"; + +const ensureData = (data: T | undefined): T => { + if (typeof data === "undefined") { + throw new Error("No data returned from server"); + } + return data; +}; + +type ProfileUpdateInput = { + firstName?: string; + lastName?: string; + phone?: string; +}; + +export const accountService = { + async getProfile() { + const response = await apiClient.GET('/me'); + return ensureData(response.data); + }, + + async updateProfile(update: ProfileUpdateInput) { + const response = await apiClient.PATCH('/me', { body: update }); + return ensureData(response.data); + }, + + async getAddress() { + const response = await apiClient.GET('/me/address'); + return ensureData
(response.data); + }, + + async updateAddress(address: Address) { + const response = await apiClient.PATCH('/me/address', { body: address }); + return ensureData
(response.data); + }, +}; diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index b5283aed..af1e644c 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -5,7 +5,7 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; -import { apiClient } from "@/core/api"; +import { apiClient, configureApiClientAuth } from "@/core/api"; import type { AuthTokens, SignupRequest, @@ -17,6 +17,62 @@ import type { AuthError, } from "@customer-portal/domain"; +const DEFAULT_TOKEN_TYPE = "Bearer"; + +type RawAuthTokens = + | AuthTokens + | (Omit & { expiresAt: string | number | Date }); + +const toIsoString = (value: string | number | Date) => { + if (value instanceof Date) { + return value.toISOString(); + } + + if (typeof value === "number") { + return new Date(value).toISOString(); + } + + return value; +}; + +const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => ({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + tokenType: tokens.tokenType ?? DEFAULT_TOKEN_TYPE, + expiresAt: toIsoString(tokens.expiresAt), +}); + +const getExpiryTime = (tokens?: AuthTokens | null) => { + if (!tokens?.expiresAt) { + return null; + } + + const expiry = Date.parse(tokens.expiresAt); + return Number.isNaN(expiry) ? null : expiry; +}; + +const hasValidAccessToken = (tokens?: AuthTokens | null) => { + if (!tokens?.accessToken) { + return false; + } + + const expiry = getExpiryTime(tokens); + if (expiry === null) { + return false; + } + + return Date.now() < expiry; +}; + +const buildAuthorizationHeader = (tokens?: AuthTokens | null) => { + if (!tokens?.accessToken) { + return undefined; + } + + const tokenType = tokens.tokenType ?? DEFAULT_TOKEN_TYPE; + return `${tokenType} ${tokens.accessToken}`; +}; + interface AuthState { isAuthenticated: boolean; user: AuthUser | null; @@ -54,7 +110,7 @@ interface AuthStoreState extends AuthState { setLoading: (loading: boolean) => void; setError: (error: string | null) => void; setUser: (user: AuthUser | null) => void; - setTokens: (tokens: AuthTokens | null) => void; + setTokens: (tokens: RawAuthTokens | null) => void; } export const useAuthStore = create()( @@ -76,9 +132,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/login', { body: credentials }); if (response.data) { + const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens); set({ user: response.data.user, - tokens: response.data.tokens, + tokens, isAuthenticated: true, loading: false, error: null, @@ -104,9 +161,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/signup', { body: data }); if (response.data) { + const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens); set({ user: response.data.user, - tokens: response.data.tokens, + tokens, isAuthenticated: true, loading: false, error: null, @@ -164,9 +222,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/reset-password', { body: data }); const { user, tokens } = response.data!; + const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens); set({ user, - tokens, + tokens: normalizedTokens, isAuthenticated: true, loading: false, error: null, @@ -194,9 +253,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/change-password', { body: data }); const { user, tokens: newTokens } = response.data!; + const normalizedTokens = normalizeAuthTokens(newTokens as RawAuthTokens); set({ user, - tokens: newTokens, + tokens: normalizedTokens, loading: false, error: null, }); @@ -240,9 +300,10 @@ export const useAuthStore = create()( try { const response = await apiClient.POST('/auth/set-password', { body: { email, password } }); const { user, tokens } = response.data!; + const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens); set({ user, - tokens, + tokens: normalizedTokens, isAuthenticated: true, loading: false, error: null, @@ -264,22 +325,26 @@ export const useAuthStore = create()( checkAuth: async () => { const { tokens } = get(); - if (!tokens?.accessToken) { - set({ isAuthenticated: false, loading: false, user: null, tokens: null, hasCheckedAuth: true }); - return; - } - - // Check if token is expired - if (Date.now() >= tokens.expiresAt) { - set({ isAuthenticated: false, loading: false, user: null, tokens: null, hasCheckedAuth: true }); + if (!hasValidAccessToken(tokens)) { + set({ + isAuthenticated: false, + loading: false, + user: null, + tokens: null, + hasCheckedAuth: true, + }); return; } set({ loading: true }); try { const response = await apiClient.GET('/me'); - const user = response.data!; - set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); + const user = response.data ?? null; + if (user) { + set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); + } else { + set({ loading: false, hasCheckedAuth: true }); + } } catch (error) { // Token is invalid, clear auth state console.info("Token validation failed, clearing auth state"); @@ -301,8 +366,14 @@ export const useAuthStore = create()( return; } + const expiry = getExpiryTime(tokens); + if (expiry === null) { + await checkAuth(); + return; + } + // Check if token needs refresh (expires within 5 minutes) - if (Date.now() >= tokens.expiresAt - 5 * 60 * 1000) { // 5 minutes before expiry + if (Date.now() >= expiry - 5 * 60 * 1000) { // For now, just re-validate the token // In a real implementation, you would call a refresh token endpoint await checkAuth(); @@ -318,7 +389,8 @@ export const useAuthStore = create()( setUser: (user: AuthUser | null) => set({ user }), - setTokens: (tokens: AuthTokens | null) => set({ tokens }), + setTokens: (tokens: RawAuthTokens | null) => + set({ tokens: tokens ? normalizeAuthTokens(tokens) : null }), }), { name: "auth-store", @@ -352,18 +424,45 @@ export const useAuthStore = create()( ) ); +const resolveAuthorizationHeader = () => + buildAuthorizationHeader(useAuthStore.getState().tokens); + +configureApiClientAuth(resolveAuthorizationHeader); + +export const getAuthorizationHeader = resolveAuthorizationHeader; + +export const selectAuthTokens = (state: AuthStoreState) => state.tokens; +export const selectIsAuthenticated = (state: AuthStoreState) => state.isAuthenticated; +export const selectHasValidAccessToken = (state: AuthStoreState) => + hasValidAccessToken(state.tokens); + +export const useAuthSession = () => + useAuthStore(state => ({ + isAuthenticated: state.isAuthenticated, + hasValidToken: hasValidAccessToken(state.tokens), + tokens: state.tokens, + })); + // Session timeout detection let sessionTimeoutId: NodeJS.Timeout | null = null; export const startSessionTimeout = () => { const checkSession = () => { const state = useAuthStore.getState(); - if (state.tokens?.accessToken) { - if (Date.now() >= state.tokens.expiresAt) { - void state.logout(); - } else { - void state.refreshSession(); - } + if (!state.tokens?.accessToken) { + return; + } + + const expiry = getExpiryTime(state.tokens); + if (expiry === null) { + void state.logout(); + return; + } + + if (Date.now() >= expiry) { + void state.logout(); + } else { + void state.refreshSession(); } }; diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 32ac755d..d2cc49e1 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -4,7 +4,7 @@ */ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useAuthStore } from "@/features/auth/services/auth.store"; +import { useAuthSession } from "@/features/auth/services/auth.store"; import { apiClient } from "@/core/api"; import type { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain"; @@ -17,26 +17,23 @@ interface UseSubscriptionsOptions { */ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { const { status } = options; - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscriptions", status], queryFn: async () => { - if (!tokens?.accessToken) { - throw new Error("Authentication required"); - } - const params = new URLSearchParams({ ...(status && { status }), }); - const res = await apiClient.get( - `/subscriptions?${params}` - ); - return res.data as SubscriptionList | Subscription[]; + if (!hasValidToken) { + throw new Error("Authentication required"); + } + + return apiClient.get(`/subscriptions?${params}`); }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -44,21 +41,20 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { * Hook to fetch active subscriptions only */ export function useActiveSubscriptions() { - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscriptions", "active"], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const res = await apiClient.get(`/subscriptions/active`); - return res.data as Subscription[]; + return apiClient.get(`/subscriptions/active`); }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -66,7 +62,7 @@ export function useActiveSubscriptions() { * Hook to fetch subscription statistics */ export function useSubscriptionStats() { - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery<{ total: number; @@ -77,28 +73,22 @@ export function useSubscriptionStats() { }>({ queryKey: ["subscriptions", "stats"], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const res = await apiClient.get<{ + const stats = await apiClient.get<{ total: number; active: number; suspended: number; cancelled: number; pending: number; }>(`/subscriptions/stats`); - return res.data as { - total: number; - active: number; - suspended: number; - cancelled: number; - pending: number; - }; + return stats; }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -106,21 +96,20 @@ export function useSubscriptionStats() { * Hook to fetch a specific subscription */ export function useSubscription(subscriptionId: number) { - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscription", subscriptionId], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } - const res = await apiClient.get(`/subscriptions/${subscriptionId}`); - return res.data as Subscription; + return apiClient.get(`/subscriptions/${subscriptionId}`); }, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated && hasValidToken, }); } @@ -132,12 +121,12 @@ export function useSubscriptionInvoices( options: { page?: number; limit?: number } = {} ) { const { page = 1, limit = 10 } = options; - const { tokens, isAuthenticated } = useAuthStore(); + const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ queryKey: ["subscription-invoices", subscriptionId, page, limit], queryFn: async () => { - if (!tokens?.accessToken) { + if (!hasValidToken) { throw new Error("Authentication required"); } @@ -145,14 +134,13 @@ export function useSubscriptionInvoices( page: page.toString(), limit: limit.toString(), }); - const res = await apiClient.get( + return apiClient.get( `/subscriptions/${subscriptionId}/invoices?${params}` ); - return unwrap(res) as InvoiceList; }, staleTime: 60 * 1000, // 1 minute gcTime: 5 * 60 * 1000, // 5 minutes - enabled: isAuthenticated && !!tokens?.accessToken && !!subscriptionId, + enabled: isAuthenticated && hasValidToken && !!subscriptionId, }); } @@ -164,8 +152,7 @@ export function useSubscriptionAction() { return useMutation({ mutationFn: async ({ id, action }: { id: number; action: string }) => { - const res = await apiClient.post(`/subscriptions/${id}/actions`, { action }); - return unwrap(res); + return apiClient.post(`/subscriptions/${id}/actions`, { action }); }, onSuccess: (_, { id }) => { // Invalidate relevant queries after successful action diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 520cf73c..a9ba5fb9 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,6 +1,7 @@ -// Re-export generated client and types export * as ApiTypes from "./__generated__/types"; export { createClient } from "./runtime/client"; -export type { ApiClient } from "./runtime/client"; - - +export type { + ApiClient, + AuthHeaderResolver, + CreateClientOptions, +} from "./runtime/client"; diff --git a/packages/api-client/src/runtime/client.ts b/packages/api-client/src/runtime/client.ts index bf4d87af..503e4211 100644 --- a/packages/api-client/src/runtime/client.ts +++ b/packages/api-client/src/runtime/client.ts @@ -1,12 +1,42 @@ -// Defer importing openapi-fetch until codegen deps are installed. -import createFetchClient from "openapi-fetch"; +import createOpenApiClient from "openapi-fetch"; import type { paths } from "../__generated__/types"; -export type ApiClient = ReturnType>; +export type ApiClient = ReturnType>; -export function createClient(baseUrl: string, _getAuthHeader?: () => string | undefined): ApiClient { - // Consumers can pass headers per call using the client's request options. - return createFetchClient({ baseUrl }); +export type AuthHeaderResolver = () => string | undefined; + +export interface CreateClientOptions { + getAuthHeader?: AuthHeaderResolver; } +export function createClient( + baseUrl: string, + options: CreateClientOptions = {} +): ApiClient { + const client = createOpenApiClient({ baseUrl }); + if (typeof client.use === "function" && options.getAuthHeader) { + const resolveAuthHeader = options.getAuthHeader; + + client.use({ + onRequest({ request }: { request: Request }) { + if (!request || typeof request.headers?.has !== "function") { + return; + } + + if (request.headers.has("Authorization")) { + return; + } + + const headerValue = resolveAuthHeader(); + if (!headerValue) { + return; + } + + request.headers.set("Authorization", headerValue); + }, + } as never); + } + + return client; +}