From d6f7c50e7bf1d3efb1bc7d1a32da953a9a46ed1c Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 5 Nov 2025 15:47:06 +0900 Subject: [PATCH] Refactor Salesforce request handling and improve logging - Moved metrics tracking and logging from the queueing phase to the execution phase in SalesforceRequestQueueService for better accuracy. - Updated CSRF token generation in CsrfController to accept parameters in a more flexible manner. - Enhanced CacheService to handle immediate expiry requests without leaking stale values. - Improved error handling and re-authentication logic in SalesforceConnection for better resilience during session expiration. - Refactored logout functionality in AuthFacade to handle optional userId and improve logging during token revocation. - Updated AuthController to apply rate limit headers and improved type handling in various request contexts. - Streamlined imports and improved overall code organization across multiple modules for better maintainability. --- .../salesforce-request-queue.service.ts | 30 +- .../controllers/csrf.controller.spec.ts | 74 ++++ .../security/controllers/csrf.controller.ts | 4 +- apps/bff/src/infra/cache/cache.service.ts | 15 +- .../salesforce-connection.service.spec.ts | 68 ++++ .../services/salesforce-connection.service.ts | 245 +++++++----- .../modules/auth/application/auth.facade.ts | 20 +- .../auth/presentation/http/auth.controller.ts | 39 +- .../guards/failed-login-throttle.guard.ts | 16 +- .../http/guards/global-auth.guard.ts | 36 ++ .../interceptors/login-result.interceptor.ts | 12 +- .../modules/currency/currency.controller.ts | 14 +- apps/bff/src/modules/health/health.module.ts | 6 +- .../orders/services/order-events.service.ts | 3 +- apps/portal/Dockerfile | 2 - apps/portal/next.config.mjs | 2 - apps/portal/package.json | 1 - .../components/molecules/error-boundary.tsx | 2 +- .../features/account/hooks/useProfileData.ts | 2 +- .../auth/components/SessionTimeoutWarning.tsx | 2 +- .../src/features/auth/services/auth.store.ts | 2 +- .../components/InvoiceTable/InvoiceTable.tsx | 2 +- .../features/billing/views/InvoiceDetail.tsx | 2 +- .../features/billing/views/PaymentMethods.tsx | 2 +- .../components/base/AddressConfirmation.tsx | 2 +- .../dashboard/views/DashboardView.tsx | 2 +- .../features/orders/components/OrderCard.tsx | 139 +++---- .../features/orders/utils/order-display.ts | 38 +- .../src/features/orders/views/OrderDetail.tsx | 370 +++++++++--------- .../support/views/NewSupportCaseView.tsx | 2 +- apps/portal/src/lib/api/helpers.ts | 31 -- apps/portal/src/lib/api/index.ts | 4 +- apps/portal/src/lib/hooks/useCurrency.ts | 13 +- apps/portal/src/lib/hooks/useLocalStorage.ts | 2 +- apps/portal/src/lib/logger.ts | 8 + .../src/lib/services/currency.service.ts | 51 +-- apps/portal/tsconfig.json | 2 - env/portal-backend.env.sample | 21 + packages/domain/package.json | 1 + scripts/dev/manage.sh | 6 +- 40 files changed, 764 insertions(+), 529 deletions(-) create mode 100644 apps/bff/src/core/security/controllers/csrf.controller.spec.ts create mode 100644 apps/bff/src/integrations/salesforce/services/salesforce-connection.service.spec.ts delete mode 100644 apps/portal/src/lib/api/helpers.ts 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 ef4a6cb7..b85b67b1 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 @@ -207,22 +207,22 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest const isLongRunning = options.isLongRunning || false; const queue = isLongRunning ? longRunningQueue : standardQueue; - this.metrics.totalRequests++; - this.metrics.dailyApiUsage++; - this.updateQueueMetrics(); - - this.logger.debug("Queueing Salesforce request", { - requestId, - isLongRunning, - queueSize: queue.size, - pending: queue.pending, - priority: options.priority || 0, - dailyUsage: this.metrics.dailyApiUsage, - }); - try { const result = (await queue.add( async () => { + this.metrics.totalRequests++; + this.metrics.dailyApiUsage++; + this.updateQueueMetrics(); + + this.logger.debug("Executing Salesforce request", { + requestId, + isLongRunning, + queueSize: queue.size, + pending: queue.pending, + priority: options.priority || 0, + dailyUsage: this.metrics.dailyApiUsage, + }); + const waitTime = Date.now() - startTime; this.recordWaitTime(waitTime); @@ -277,10 +277,6 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest )) as T; return result; - } catch (error) { - this.metrics.failedRequests++; - this.metrics.lastErrorTime = new Date(); - throw error; } finally { this.updateQueueMetrics(); } diff --git a/apps/bff/src/core/security/controllers/csrf.controller.spec.ts b/apps/bff/src/core/security/controllers/csrf.controller.spec.ts new file mode 100644 index 00000000..bafd210c --- /dev/null +++ b/apps/bff/src/core/security/controllers/csrf.controller.spec.ts @@ -0,0 +1,74 @@ +import type { Request, Response } from "express"; +import { Logger } from "nestjs-pino"; +import { CsrfController } from "./csrf.controller"; +import type { CsrfService, CsrfTokenData } from "../services/csrf.service"; + +const createMockResponse = () => { + const cookie = jest.fn(); + const json = jest.fn(); + return { + cookie, + json, + } as unknown as Response; +}; + +describe("CsrfController", () => { + const csrfToken: CsrfTokenData = { + token: "token-value", + secret: "secret-value", + expiresAt: new Date(), + }; + + const makeController = (overrides: Partial = {}) => { + const csrfService: Partial = { + generateToken: jest.fn().mockReturnValue(csrfToken), + invalidateUserTokens: jest.fn(), + ...overrides, + }; + + const logger = { debug: jest.fn() } as unknown as Logger; + return { + controller: new CsrfController(csrfService as CsrfService, logger), + csrfService, + }; + }; + + it("passes session and user identifiers to generateToken in the correct argument order", () => { + const { controller, csrfService } = makeController(); + const res = createMockResponse(); + + const req = { + user: { id: "user-123", sessionId: "session-456" }, + cookies: {}, + get: jest.fn(), + ip: "127.0.0.1", + } as unknown as Request; + + controller.getCsrfToken(req, res); + + expect(csrfService.generateToken).toHaveBeenCalledWith( + undefined, + "session-456", + "user-123" + ); + }); + + it("defaults to anonymous user during refresh and still forwards identifiers correctly", () => { + const { controller, csrfService } = makeController(); + const res = createMockResponse(); + + const req = { + cookies: { "connect.sid": "cookie-session" }, + get: jest.fn(), + ip: "127.0.0.1", + } as unknown as Request; + + controller.refreshCsrfToken(req, res); + + expect(csrfService.generateToken).toHaveBeenCalledWith( + undefined, + "cookie-session", + "anonymous" + ); + }); +}); diff --git a/apps/bff/src/core/security/controllers/csrf.controller.ts b/apps/bff/src/core/security/controllers/csrf.controller.ts index 11e3b2a8..71cca06c 100644 --- a/apps/bff/src/core/security/controllers/csrf.controller.ts +++ b/apps/bff/src/core/security/controllers/csrf.controller.ts @@ -23,7 +23,7 @@ export class CsrfController { const userId = req.user?.id; // Generate new CSRF token - const tokenData = this.csrfService.generateToken(sessionId, userId); + const tokenData = this.csrfService.generateToken(undefined, sessionId, userId); // Set CSRF secret in secure cookie res.cookie("csrf-secret", tokenData.secret, { @@ -58,7 +58,7 @@ export class CsrfController { this.csrfService.invalidateUserTokens(userId); // Generate new CSRF token - const tokenData = this.csrfService.generateToken(sessionId, userId); + const tokenData = this.csrfService.generateToken(undefined, sessionId, userId); // Set CSRF secret in secure cookie res.cookie("csrf-secret", tokenData.secret, { diff --git a/apps/bff/src/infra/cache/cache.service.ts b/apps/bff/src/infra/cache/cache.service.ts index ff5752e0..e9ba1d79 100644 --- a/apps/bff/src/infra/cache/cache.service.ts +++ b/apps/bff/src/infra/cache/cache.service.ts @@ -26,11 +26,18 @@ export class CacheService { async set(key: string, value: unknown, ttlSeconds?: number): Promise { const serialized = JSON.stringify(value); - if (ttlSeconds) { - await this.redis.setex(key, ttlSeconds, serialized); - } else { - await this.redis.set(key, serialized); + if (ttlSeconds !== undefined) { + const ttl = Math.max(0, Math.floor(ttlSeconds)); + if (ttl > 0) { + await this.redis.set(key, serialized, "EX", ttl); + } else { + // Allow callers to request immediate expiry without leaking stale values + await this.redis.set(key, serialized, "PX", 1); + } + return; } + + await this.redis.set(key, serialized); } async del(key: string): Promise { diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.spec.ts b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.spec.ts new file mode 100644 index 00000000..82be45aa --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.spec.ts @@ -0,0 +1,68 @@ +import type { Logger } from "nestjs-pino"; +import type { ConfigService } from "@nestjs/config"; +import { SalesforceConnection } from "./salesforce-connection.service"; +import type { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service"; + +describe("SalesforceConnection", () => { + const createService = () => { + const configService = { + get: jest.fn(), + } as unknown as ConfigService; + + const requestQueue: Partial = { + execute: jest.fn().mockImplementation(async (fn) => fn()), + executeHighPriority: jest.fn().mockImplementation(async (fn) => fn()), + }; + + const logger = { + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + log: jest.fn(), + } as unknown as Logger; + + const service = new SalesforceConnection(configService, requestQueue as SalesforceRequestQueueService, logger); + + // Override internal connection with simple stubs + const queryMock = jest.fn().mockResolvedValue("query-result"); + service["connection"] = { + query: queryMock, + sobject: jest.fn().mockReturnValue({ + create: jest.fn().mockResolvedValue({ id: "001" }), + update: jest.fn().mockResolvedValue({}), + }), + } as unknown as typeof service["connection"]; + + jest.spyOn(service as unknown as { ensureConnected(): Promise }, "ensureConnected").mockResolvedValue(); + + return { + service, + requestQueue, + queryMock, + }; + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("routes standard queries through the request queue with derived priority metadata", async () => { + const { service, requestQueue, queryMock } = createService(); + + await service.query("SELECT Id FROM Account WHERE Id = '001'"); + + expect(requestQueue.execute).toHaveBeenCalledTimes(1); + const [, options] = (requestQueue.execute as jest.Mock).mock.calls[0]; + expect(options).toMatchObject({ priority: 8, isLongRunning: false }); + expect(queryMock).toHaveBeenCalledTimes(1); + }); + + it("routes SObject create operations through the high-priority queue", async () => { + const { service, requestQueue } = createService(); + + const sobject = service.sobject("Order"); + await sobject.create({ Name: "Test" }); + + expect(requestQueue.executeHighPriority).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts index e7977636..b2241f66 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts @@ -242,42 +242,50 @@ export class SalesforceConnection { // Expose connection methods with automatic re-authentication async query(soql: string): Promise { + const priority = this.getQueryPriority(soql); + const isLongRunning = this.isLongRunningQuery(soql); + try { - // Ensure we have a base URL and token - await this.ensureConnected(); - return await this.connection.query(soql); + return await this.requestQueue.execute( + async () => { + await this.ensureConnected(); + try { + return await this.connection.query(soql); + } catch (error: unknown) { + if (this.isSessionExpiredError(error)) { + const reAuthStartTime = Date.now(); + this.logger.warn("Salesforce session expired, attempting to re-authenticate", { + originalError: getErrorMessage(error), + tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null, + tokenExpiresAt: this.tokenExpiresAt ? new Date(this.tokenExpiresAt).toISOString() : null, + }); + + try { + await this.connect(true); + + const reAuthDuration = Date.now() - reAuthStartTime; + this.logger.debug("Retrying query after re-authentication", { + reAuthDuration, + }); + + return await this.connection.query(soql); + } catch (retryError) { + const reAuthDuration = Date.now() - reAuthStartTime; + this.logger.error("Failed to re-authenticate or retry query", { + originalError: getErrorMessage(error), + retryError: getErrorMessage(retryError), + reAuthDuration, + }); + throw retryError; + } + } + + throw error; + } + }, + { priority, isLongRunning } + ); } catch (error: unknown) { - // Check if this is a session expiration error - if (this.isSessionExpiredError(error)) { - const reAuthStartTime = Date.now(); - this.logger.warn("Salesforce session expired, attempting to re-authenticate", { - originalError: getErrorMessage(error), - tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null, - tokenExpiresAt: this.tokenExpiresAt ? new Date(this.tokenExpiresAt).toISOString() : null, - }); - - try { - // Re-authenticate - await this.connect(true); - - const reAuthDuration = Date.now() - reAuthStartTime; - this.logger.debug("Retrying query after re-authentication", { - reAuthDuration, - }); - - return await this.connection.query(soql); - } catch (retryError) { - const reAuthDuration = Date.now() - reAuthStartTime; - this.logger.error("Failed to re-authenticate or retry query", { - originalError: getErrorMessage(error), - retryError: getErrorMessage(retryError), - reAuthDuration, - }); - throw retryError; - } - } - - // Re-throw other errors as-is throw error; } } @@ -305,86 +313,90 @@ export class SalesforceConnection { // Return a wrapper that handles session expiration for SObject operations return { create: async (data: object) => { - try { - await this.ensureConnected(); - return await originalSObject.create(data); - } catch (error: unknown) { - if (this.isSessionExpiredError(error)) { - const reAuthStartTime = Date.now(); - this.logger.warn( - "Salesforce session expired during SObject create, attempting to re-authenticate", - { - sobjectType: type, - originalError: getErrorMessage(error), + return this.requestQueue.executeHighPriority(async () => { + try { + await this.ensureConnected(); + return await originalSObject.create(data); + } catch (error: unknown) { + if (this.isSessionExpiredError(error)) { + const reAuthStartTime = Date.now(); + this.logger.warn( + "Salesforce session expired during SObject create, attempting to re-authenticate", + { + sobjectType: type, + originalError: getErrorMessage(error), + } + ); + + try { + await this.connect(true); + const reAuthDuration = Date.now() - reAuthStartTime; + this.logger.debug("Retrying SObject create after re-authentication", { + sobjectType: type, + reAuthDuration, + }); + + const newSObject = this.connection.sobject(type); + return await newSObject.create(data); + } catch (retryError) { + const reAuthDuration = Date.now() - reAuthStartTime; + this.logger.error("Failed to re-authenticate or retry SObject create", { + sobjectType: type, + originalError: getErrorMessage(error), + retryError: getErrorMessage(retryError), + reAuthDuration, + }); + throw retryError; } - ); - - try { - await this.connect(true); - const reAuthDuration = Date.now() - reAuthStartTime; - this.logger.debug("Retrying SObject create after re-authentication", { - sobjectType: type, - reAuthDuration, - }); - - const newSObject = this.connection.sobject(type); - return await newSObject.create(data); - } catch (retryError) { - const reAuthDuration = Date.now() - reAuthStartTime; - this.logger.error("Failed to re-authenticate or retry SObject create", { - sobjectType: type, - originalError: getErrorMessage(error), - retryError: getErrorMessage(retryError), - reAuthDuration, - }); - throw retryError; } + throw error; } - throw error; - } + }); }, update: async (data: object & { Id: string }) => { - try { - await this.ensureConnected(); - return await originalSObject.update(data); - } catch (error: unknown) { - if (this.isSessionExpiredError(error)) { - const reAuthStartTime = Date.now(); - this.logger.warn( - "Salesforce session expired during SObject update, attempting to re-authenticate", - { - sobjectType: type, - recordId: data.Id, - originalError: getErrorMessage(error), + return this.requestQueue.executeHighPriority(async () => { + try { + await this.ensureConnected(); + return await originalSObject.update(data); + } catch (error: unknown) { + if (this.isSessionExpiredError(error)) { + const reAuthStartTime = Date.now(); + this.logger.warn( + "Salesforce session expired during SObject update, attempting to re-authenticate", + { + sobjectType: type, + recordId: data.Id, + originalError: getErrorMessage(error), + } + ); + + try { + await this.connect(true); + const reAuthDuration = Date.now() - reAuthStartTime; + this.logger.debug("Retrying SObject update after re-authentication", { + sobjectType: type, + recordId: data.Id, + reAuthDuration, + }); + + const newSObject = this.connection.sobject(type); + return await newSObject.update(data); + } catch (retryError) { + const reAuthDuration = Date.now() - reAuthStartTime; + this.logger.error("Failed to re-authenticate or retry SObject update", { + sobjectType: type, + recordId: data.Id, + originalError: getErrorMessage(error), + retryError: getErrorMessage(retryError), + reAuthDuration, + }); + throw retryError; } - ); - - try { - await this.connect(true); - const reAuthDuration = Date.now() - reAuthStartTime; - this.logger.debug("Retrying SObject update after re-authentication", { - sobjectType: type, - recordId: data.Id, - reAuthDuration, - }); - - const newSObject = this.connection.sobject(type); - return await newSObject.update(data); - } catch (retryError) { - const reAuthDuration = Date.now() - reAuthStartTime; - this.logger.error("Failed to re-authenticate or retry SObject update", { - sobjectType: type, - recordId: data.Id, - originalError: getErrorMessage(error), - retryError: getErrorMessage(retryError), - reAuthDuration, - }); - throw retryError; } + throw error; } - throw error; - } + }); }, }; } @@ -440,7 +452,28 @@ export class SalesforceConnection { async queryHighPriority(soql: string): Promise { return this.requestQueue.executeHighPriority(async () => { await this.ensureConnected(); - return await this.connection.query(soql); + try { + return await this.connection.query(soql); + } catch (error: unknown) { + if (this.isSessionExpiredError(error)) { + const reAuthStartTime = Date.now(); + this.logger.warn( + "Salesforce session expired during high-priority query, attempting to re-authenticate", + { + originalError: getErrorMessage(error), + } + ); + + await this.connect(true); + const reAuthDuration = Date.now() - reAuthStartTime; + this.logger.debug("Retrying high-priority query after re-authentication", { + reAuthDuration, + }); + + return await this.connection.query(soql); + } + throw error; + } }); } diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index b9366490..a17eb65d 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -297,18 +297,22 @@ export class AuthFacade { } } - async logout(userId: string, token?: string, request?: Request): Promise { + async logout(userId?: string, token?: string, request?: Request): Promise { if (token) { await this.tokenBlacklistService.blacklistToken(token); } - try { - await this.tokenService.revokeAllUserTokens(userId); - } catch (error) { - this.logger.warn("Failed to revoke refresh tokens during logout", { - userId, - error: getErrorMessage(error), - }); + if (userId) { + try { + await this.tokenService.revokeAllUserTokens(userId); + } catch (error) { + this.logger.warn("Failed to revoke refresh tokens during logout", { + userId, + error: getErrorMessage(error), + }); + } + } else { + this.logger.debug("Skipping refresh token revocation during logout – userId unavailable"); } await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, request, true); 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 bd657c75..d4b3eb76 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -12,13 +12,18 @@ import { } from "@nestjs/common"; import type { Request, Response } from "express"; import { Throttle } from "@nestjs/throttler"; +import { JwtService } from "@nestjs/jwt"; import { AuthFacade } from "@bff/modules/auth/application/auth.facade"; import { LocalAuthGuard } from "./guards/local-auth.guard"; import { AuthThrottleGuard } from "./guards/auth-throttle.guard"; -import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard"; +import { + FailedLoginThrottleGuard, + type RequestWithRateLimit, +} from "./guards/failed-login-throttle.guard"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor"; import { Public } from "../../decorators/public.decorator"; import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types"; // Import Zod schemas from domain import { @@ -100,7 +105,7 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => { @Controller("auth") export class AuthController { - constructor(private authFacade: AuthFacade) {} + constructor(private authFacade: AuthFacade, private readonly jwtService: JwtService) {} private setAuthCookies(res: Response, tokens: AuthTokens): void { const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt); @@ -121,6 +126,10 @@ export class AuthController { res.setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" }); } + private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void { + FailedLoginThrottleGuard.applyRateLimitHeaders(req, res); + } + @Public() @Post("validate-signup") @UseGuards(AuthThrottleGuard) @@ -173,21 +182,41 @@ export class AuthController { @UseInterceptors(LoginResultInterceptor) @Post("login") async login( - @Req() req: Request & { user: { id: string; email: string; role: string } }, + @Req() req: RequestWithUser & RequestWithRateLimit, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.login(req.user, req); this.setAuthCookies(res, result.tokens); + this.applyAuthRateLimitHeaders(req, res); return result; } + @Public() @Post("logout") async logout( - @Req() req: RequestWithCookies & { user: { id: string } }, + @Req() req: RequestWithCookies & { user?: { id: string } }, @Res({ passthrough: true }) res: Response ) { const token = extractTokenFromRequest(req); - await this.authFacade.logout(req.user.id, token, req as Request); + let userId = req.user?.id; + + if (!userId && token) { + try { + const payload = await this.jwtService.verifyAsync<{ sub?: string }>(token, { + ignoreExpiration: true, + }); + + if (payload?.sub) { + userId = payload.sub; + } + } catch (error) { + // Ignore verification errors – we still want to clear client cookies. + } + } + + await this.authFacade.logout(userId, token, req as Request); + + // Always clear cookies, even if session expired this.clearAuthCookies(res); return { message: "Logout successful" }; } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts index be42786d..26795e7f 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts @@ -1,11 +1,11 @@ import { Injectable, ExecutionContext } from "@nestjs/common"; -import type { Request } from "express"; +import type { Request, Response } from "express"; import { AuthRateLimitService, type RateLimitOutcome, } from "../../../infra/rate-limiting/auth-rate-limit.service"; -type RequestWithRateLimit = Request & { __authRateLimit?: RateLimitOutcome }; +export type RequestWithRateLimit = Request & { __authRateLimit?: RateLimitOutcome }; @Injectable() export class FailedLoginThrottleGuard { @@ -26,4 +26,16 @@ export class FailedLoginThrottleGuard { await this.authRateLimitService.clearLoginAttempts(request); } } + + static applyRateLimitHeaders( + request: RequestWithRateLimit, + response: Response + ): void { + const outcome = request.__authRateLimit; + if (!outcome) return; + + response.setHeader("X-RateLimit-Remaining", String(outcome.remainingPoints)); + response.setHeader("X-RateLimit-Reset", String(Math.ceil(outcome.msBeforeNext / 1000))); + response.setHeader("X-Auth-Captcha", outcome.needsCaptcha ? "required" : "optional"); + } } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index 6e1717d6..a1364e30 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -49,6 +49,7 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { override async canActivate(context: ExecutionContext): Promise { const request = this.getRequest(context); const route = `${request.method} ${request.url}`; + const isLogoutRoute = this.isLogoutRoute(request); // Check if the route is marked as public const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ @@ -65,6 +66,10 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { // First, run the standard JWT authentication const canActivate = await super.canActivate(context); if (!canActivate) { + if (isLogoutRoute) { + this.logger.debug(`Allowing logout request without active session: ${route}`); + return true; + } this.logger.warn(`JWT authentication failed for route: ${route}`); return false; } @@ -84,6 +89,10 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { return true; } catch (error) { if (error instanceof UnauthorizedException) { + if (isLogoutRoute) { + this.logger.debug(`Allowing logout request with expired or invalid token: ${route}`); + return true; + } const token = extractTokenFromRequest(request); const log = typeof token === "string" @@ -116,4 +125,31 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { (!candidate.route || typeof candidate.route === "object") ); } + + private isLogoutRoute(request: RequestWithRoute): boolean { + if (!request || typeof request.method !== "string" || typeof request.url !== "string") { + return false; + } + + if (request.method.toUpperCase() !== "POST") { + return false; + } + + const routePath = request.route?.path; + if (routePath && this.isLogoutPath(routePath)) { + return true; + } + + const normalizedUrl = request.url.split("?")[0] ?? ""; + return this.isLogoutPath(normalizedUrl); + } + + private isLogoutPath(path: string): boolean { + if (typeof path !== "string" || path.length === 0) { + return false; + } + + const normalized = path.endsWith("/") ? path.slice(0, -1) : path; + return normalized === "/auth/logout" || normalized === "/api/auth/logout"; + } } diff --git a/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts b/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts index 1c17f40f..e3aa95b5 100644 --- a/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts +++ b/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts @@ -7,8 +7,11 @@ import { } from "@nestjs/common"; import { Observable, defer } from "rxjs"; import { tap, catchError } from "rxjs/operators"; -import { FailedLoginThrottleGuard } from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard"; -import type { Request } from "express"; +import { + FailedLoginThrottleGuard, + type RequestWithRateLimit, +} from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard"; +import type { Request, Response } from "express"; @Injectable() export class LoginResultInterceptor implements NestInterceptor { @@ -16,14 +19,16 @@ export class LoginResultInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const rawRequest = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); if (!this.isExpressRequest(rawRequest)) { throw new UnauthorizedException("Invalid request context"); } - const request: Request = rawRequest; + const request: RequestWithRateLimit = rawRequest as RequestWithRateLimit; return next.handle().pipe( tap(() => { void this.failedLoginGuard.handleLoginResult(request, true); + FailedLoginThrottleGuard.applyRateLimitHeaders(request, response); }), catchError(error => defer(async () => { @@ -37,6 +42,7 @@ export class LoginResultInterceptor implements NestInterceptor { if (isAuthError) { await this.failedLoginGuard.handleLoginResult(request, false); + FailedLoginThrottleGuard.applyRateLimitHeaders(request, response); } throw error; diff --git a/apps/bff/src/modules/currency/currency.controller.ts b/apps/bff/src/modules/currency/currency.controller.ts index 7cc67c05..26cf4d33 100644 --- a/apps/bff/src/modules/currency/currency.controller.ts +++ b/apps/bff/src/modules/currency/currency.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get } from "@nestjs/common"; import { Public } from "@bff/modules/auth/decorators/public.decorator"; import { WhmcsCurrencyService } from "../../integrations/whmcs/services/whmcs-currency.service"; +import type { WhmcsCurrency } from "@customer-portal/domain/billing"; @Controller("currency") export class CurrencyController { @@ -8,20 +9,13 @@ export class CurrencyController { @Public() @Get("default") - getDefaultCurrency() { - const defaultCurrency = this.currencyService.getDefaultCurrency(); - return { - code: defaultCurrency.code, - prefix: defaultCurrency.prefix, - suffix: defaultCurrency.suffix, - format: defaultCurrency.format, - rate: defaultCurrency.rate, - }; + getDefaultCurrency(): WhmcsCurrency { + return this.currencyService.getDefaultCurrency(); } @Public() @Get("all") - getAllCurrencies() { + getAllCurrencies(): WhmcsCurrency[] { return this.currencyService.getAllCurrencies(); } } diff --git a/apps/bff/src/modules/health/health.module.ts b/apps/bff/src/modules/health/health.module.ts index 68b83a79..5c31bc4e 100644 --- a/apps/bff/src/modules/health/health.module.ts +++ b/apps/bff/src/modules/health/health.module.ts @@ -2,9 +2,11 @@ import { Module } from "@nestjs/common"; import { HealthController } from "./health.controller"; import { PrismaModule } from "@bff/infra/database/prisma.module"; import { ConfigModule } from "@nestjs/config"; +import { QueueModule } from "@bff/core/queue/queue.module"; +import { QueueHealthController } from "@bff/core/health/queue-health.controller"; @Module({ - imports: [PrismaModule, ConfigModule], - controllers: [HealthController], + imports: [PrismaModule, ConfigModule, QueueModule], + controllers: [HealthController, QueueHealthController], }) export class HealthModule {} diff --git a/apps/bff/src/modules/orders/services/order-events.service.ts b/apps/bff/src/modules/orders/services/order-events.service.ts index f7c59b6c..fb7f57eb 100644 --- a/apps/bff/src/modules/orders/services/order-events.service.ts +++ b/apps/bff/src/modules/orders/services/order-events.service.ts @@ -95,7 +95,7 @@ export class OrderEventsService { }); } - private buildEvent(event: string, data: Record): MessageEvent { + private buildEvent(event: string, data: T): MessageEvent { return { data: { event, @@ -104,4 +104,3 @@ export class OrderEventsService { } satisfies MessageEvent; } } - diff --git a/apps/portal/Dockerfile b/apps/portal/Dockerfile index 36a1f13d..7e9939d5 100644 --- a/apps/portal/Dockerfile +++ b/apps/portal/Dockerfile @@ -19,7 +19,6 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ # Copy package.json files for dependency resolution COPY packages/domain/package.json ./packages/domain/ -COPY packages/logging/package.json ./packages/logging/ COPY packages/validation/package.json ./packages/validation/ COPY apps/portal/package.json ./apps/portal/ @@ -52,7 +51,6 @@ COPY --from=deps /app/node_modules ./node_modules # Build shared workspace packages first RUN pnpm --filter @customer-portal/domain build && \ - pnpm --filter @customer-portal/logging build && \ pnpm --filter @customer-portal/validation build # Build portal with standalone output diff --git a/apps/portal/next.config.mjs b/apps/portal/next.config.mjs index 5b344b9a..b6547d63 100644 --- a/apps/portal/next.config.mjs +++ b/apps/portal/next.config.mjs @@ -18,7 +18,6 @@ const nextConfig = { // Ensure workspace packages are transpiled correctly transpilePackages: [ "@customer-portal/domain", - "@customer-portal/logging", "@customer-portal/validation", ], @@ -119,7 +118,6 @@ const nextConfig = { config.resolve.alias = { ...config.resolve.alias, "@customer-portal/domain": path.join(workspaceRoot, "packages/domain"), - "@customer-portal/logging": path.join(workspaceRoot, "packages/logging/src"), "@customer-portal/validation": path.join(workspaceRoot, "packages/validation/src"), }; const preferredExtensions = [".ts", ".tsx", ".mts", ".cts"]; diff --git a/apps/portal/package.json b/apps/portal/package.json index 10caf3c0..b2914413 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -19,7 +19,6 @@ }, "dependencies": { "@customer-portal/domain": "workspace:*", - "@customer-portal/logging": "workspace:*", "@customer-portal/validation": "workspace:*", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.2.1", diff --git a/apps/portal/src/components/molecules/error-boundary.tsx b/apps/portal/src/components/molecules/error-boundary.tsx index e17ac252..fc22b838 100644 --- a/apps/portal/src/components/molecules/error-boundary.tsx +++ b/apps/portal/src/components/molecules/error-boundary.tsx @@ -1,7 +1,7 @@ "use client"; import { Component, ReactNode, ErrorInfo } from "react"; -import { log } from "@customer-portal/logging"; +import { log } from "@/lib/logger"; interface ErrorBoundaryState { hasError: boolean; diff --git a/apps/portal/src/features/account/hooks/useProfileData.ts b/apps/portal/src/features/account/hooks/useProfileData.ts index 3144de97..964a5d16 100644 --- a/apps/portal/src/features/account/hooks/useProfileData.ts +++ b/apps/portal/src/features/account/hooks/useProfileData.ts @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback } from "react"; import { useAuthStore } from "@/features/auth/services/auth.store"; import { accountService } from "@/features/account/services/account.service"; -import { logger } from "@customer-portal/logging"; +import { logger } from "@/lib/logger"; // Use centralized profile types import type { ProfileEditFormData, Address } from "@customer-portal/domain/customer"; diff --git a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx index 027b8344..712fe157 100644 --- a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx +++ b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx @@ -1,5 +1,5 @@ "use client"; -import { logger } from "@customer-portal/logging"; +import { logger } from "@/lib/logger"; import { useEffect, useRef, useState } from "react"; import { useAuthSession } from "@/features/auth/services/auth.store"; diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index be9159fc..bba6d85a 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -7,7 +7,7 @@ import { create } from "zustand"; import { apiClient } from "@/lib/api"; import { getNullableData } from "@/lib/api/response-helpers"; import { getErrorInfo } from "@/lib/utils/error-handling"; -import logger from "@customer-portal/logging"; +import { logger } from "@/lib/logger"; import { authResponseSchema, checkPasswordNeededResponseSchema, diff --git a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx index 43460ac8..92112c64 100644 --- a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx +++ b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx @@ -20,7 +20,7 @@ const { formatCurrency } = Formatting; import { cn } from "@/lib/utils"; import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling"; import { openSsoLink } from "@/features/billing/utils/sso"; -import { logger } from "@customer-portal/logging"; +import { logger } from "@/lib/logger"; interface InvoiceTableProps { invoices: Invoice[]; diff --git a/apps/portal/src/features/billing/views/InvoiceDetail.tsx b/apps/portal/src/features/billing/views/InvoiceDetail.tsx index af205a20..f867c3ef 100644 --- a/apps/portal/src/features/billing/views/InvoiceDetail.tsx +++ b/apps/portal/src/features/billing/views/InvoiceDetail.tsx @@ -7,7 +7,7 @@ import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; import { ErrorState } from "@/components/atoms/error-state"; import { CheckCircleIcon, DocumentTextIcon } from "@heroicons/react/24/outline"; import { PageLayout } from "@/components/templates/PageLayout"; -import { logger } from "@customer-portal/logging"; +import { logger } from "@/lib/logger"; import { openSsoLink } from "@/features/billing/utils/sso"; import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks"; import { diff --git a/apps/portal/src/features/billing/views/PaymentMethods.tsx b/apps/portal/src/features/billing/views/PaymentMethods.tsx index 1b313424..86518eb3 100644 --- a/apps/portal/src/features/billing/views/PaymentMethods.tsx +++ b/apps/portal/src/features/billing/views/PaymentMethods.tsx @@ -19,7 +19,7 @@ import { InlineToast } from "@/components/atoms/inline-toast"; import { Button } from "@/components/atoms/button"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { Skeleton } from "@/components/atoms/loading-skeleton"; -import { logger } from "@customer-portal/logging"; +import { logger } from "@/lib/logger"; export function PaymentMethodsContainer() { const [error, setError] = useState(null); diff --git a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx index 0da93dd5..2b4e0e80 100644 --- a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx +++ b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx @@ -7,7 +7,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard"; import { useState, useEffect, useCallback } from "react"; import { accountService } from "@/features/account/services/account.service"; -import { log } from "@customer-portal/logging"; +import { log } from "@/lib/logger"; import { StatusPill } from "@/components/atoms/status-pill"; import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { COUNTRY_OPTIONS, getCountryName } from "@/lib/constants/countries"; diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index 0e6d95f8..de00ef60 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -26,7 +26,7 @@ import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboa import { LoadingStats, LoadingTable } from "@/components/atoms"; import { ErrorState } from "@/components/atoms/error-state"; import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; -import { log } from "@customer-portal/logging"; +import { log } from "@/lib/logger"; import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling"; export function DashboardView() { diff --git a/apps/portal/src/features/orders/components/OrderCard.tsx b/apps/portal/src/features/orders/components/OrderCard.tsx index 93342e2a..0c0bc002 100644 --- a/apps/portal/src/features/orders/components/OrderCard.tsx +++ b/apps/portal/src/features/orders/components/OrderCard.tsx @@ -44,13 +44,6 @@ const SERVICE_ICON_STYLES = { default: "bg-slate-50 text-slate-600", } as const; -const STATUS_ACCENT_TONE_CLASSES = { - success: "bg-green-400/70", - info: "bg-blue-400/70", - warning: "bg-amber-400/70", - neutral: "bg-slate-200", -} as const; - const renderServiceIcon = (orderType?: string): ReactNode => { const category = getServiceCategory(orderType); switch (category) { @@ -115,7 +108,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
- - -
-
-
+ {/* Header */} +
+
+
{serviceIcon}
-
-

{serviceSummary}

-
- - Order #{order.orderNumber || String(order.id).slice(-8)} - - - {formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"} +
+

{serviceSummary}

+
+ #{order.orderNumber || String(order.id).slice(-8)} + + {formattedCreatedDate || "—"}
-

{statusDescriptor.description}

- {displayItems.length > 1 && ( -
- {displayItems.slice(1, 4).map(item => ( - - {item.name} - - ))} - {displayItems.length > 4 && ( - +{displayItems.length - 4} more - )} +
+
+ + {/* Pricing */} + {showPricing && ( +
+ {totals.monthlyTotal > 0 && ( +
+

Monthly

+

+ ¥{totals.monthlyTotal.toLocaleString()} +

+
+ )} + {totals.oneTimeTotal > 0 && ( +
+

One-Time

+

+ ¥{totals.oneTimeTotal.toLocaleString()} +

)}
-
-
- -
- {showPricing ? ( -
- {totals.monthlyTotal > 0 ? ( -

- ¥{totals.monthlyTotal.toLocaleString()} - / month -

- ) : ( -

No monthly charges

- )} - {totals.oneTimeTotal > 0 && ( -

- ¥{totals.oneTimeTotal.toLocaleString()} one-time -

- )} -
- ) : ( -

Included in plan

- )} -
-
+ )}
+
- {(isInteractive || footer) && ( -
- {isInteractive ? ( - - View details - + {/* Body */} +
+
+

{statusDescriptor.description}

+ +
+ {displayItems.length > 1 && ( +
+ {displayItems.slice(1, 4).map(item => ( + + {item.name} + + ))} + {displayItems.length > 4 && ( + + +{displayItems.length - 4} more - ) : ( - {statusDescriptor.label} )} - {footer &&
{footer}
}
)}
+ + {/* Footer */} + {isInteractive && ( +
+ + View details + + +
+ )}
); } diff --git a/apps/portal/src/features/orders/utils/order-display.ts b/apps/portal/src/features/orders/utils/order-display.ts index d067cf57..906023a9 100644 --- a/apps/portal/src/features/orders/utils/order-display.ts +++ b/apps/portal/src/features/orders/utils/order-display.ts @@ -118,10 +118,14 @@ const aggregateCharges = (group: OrderItemGroup): OrderDisplayItemCharge[] => { }; const buildGroupName = (group: OrderItemGroup): string => { - const monthlyItem = group.items.find( - item => normalizeBillingCycle(item.billingCycle ?? undefined) === "monthly" - ); - const fallbackItem = monthlyItem ?? group.items[0]; + // For bundles, combine the names + if (group.items.length > 1) { + return group.items + .map(item => item.productName || item.name || "Item") + .join(" + "); + } + + const fallbackItem = group.items[0]; return fallbackItem?.productName || fallbackItem?.name || "Service item"; }; @@ -195,22 +199,22 @@ export function buildOrderDisplayItems( return []; } - const groups = buildOrderItemGroup(items); - - return groups.map((group, groupIndex) => { - const charges = aggregateCharges(group); + // Don't group items - show each one separately + return items.map((item, index) => { + const charges = aggregateCharges({ indices: [index], items: [item] }); + const isBundled = Boolean(item.isBundledAddon); + return { - id: buildOrderItemId(group, groupIndex), - name: buildGroupName(group), - quantity: - group.items.length === 1 ? group.items[0]?.quantity ?? undefined : undefined, - status: group.items.length === 1 ? group.items[0]?.status ?? undefined : undefined, - primaryCategory: determinePrimaryCategory(group), - categories: collectCategories(group), + id: item.productId || item.sku || `order-item-${index}`, + name: item.productName || item.name || "Service item", + quantity: item.quantity ?? undefined, + status: item.status ?? undefined, + primaryCategory: resolveCategory(item), + categories: [resolveCategory(item)], charges, included: isIncludedGroup(charges), - sourceItems: group.items, - isBundle: group.items.length > 1, + sourceItems: [item], + isBundle: isBundled, }; }); } diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index 978c4998..3f511716 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -14,6 +14,7 @@ import { WrenchScrewdriverIcon, PuzzlePieceIcon, BoltIcon, + ClockIcon, Squares2X2Icon, ExclamationTriangleIcon, ArrowLeftIcon, @@ -63,43 +64,71 @@ const CATEGORY_CONFIG: Record< OrderDisplayItemCategory, { icon: typeof SparklesIcon; - badgeClass: string; label: string; } > = { service: { icon: SparklesIcon, - badgeClass: "bg-blue-50 text-blue-700", label: "Service", }, installation: { icon: WrenchScrewdriverIcon, - badgeClass: "bg-emerald-50 text-emerald-700", label: "Installation", }, addon: { icon: PuzzlePieceIcon, - badgeClass: "bg-violet-50 text-violet-700", label: "Add-on", }, activation: { icon: BoltIcon, - badgeClass: "bg-amber-50 text-amber-700", label: "Activation", }, other: { icon: Squares2X2Icon, - badgeClass: "bg-slate-100 text-slate-600", label: "Item", }, }; -const STATUS_ACCENT_TONE_CLASSES = { - success: "bg-green-400/80", - info: "bg-blue-400/80", - warning: "bg-amber-400/80", - neutral: "bg-slate-200", -} as const; +const ITEM_VISUAL_STYLES: Record< + OrderDisplayItemCategory, + { + container: string; + icon: string; + } +> = { + service: { + container: "border-blue-200 bg-white", + icon: "bg-blue-50 text-blue-600", + }, + installation: { + container: "border-green-200 bg-white", + icon: "bg-green-50 text-green-600", + }, + addon: { + container: "border-slate-200 bg-white", + icon: "bg-slate-50 text-slate-600", + }, + activation: { + container: "border-slate-200 bg-white", + icon: "bg-slate-50 text-slate-600", + }, + other: { + container: "border-slate-200 bg-white", + icon: "bg-slate-50 text-slate-600", + }, +}; + +const BUNDLE_VISUAL_STYLE = { + container: "border-purple-200 bg-white", + icon: "bg-purple-50 text-purple-600", +}; + +const getItemVisualStyle = (item: OrderDisplayItem) => { + if (item.isBundle) { + return BUNDLE_VISUAL_STYLE; + } + return ITEM_VISUAL_STYLES[item.primaryCategory] ?? ITEM_VISUAL_STYLES.other; +}; const describeCharge = (charge: OrderDisplayItemCharge): string => { if (typeof charge.suffix === "string" && charge.suffix.trim().length > 0) { @@ -146,9 +175,6 @@ export function OrderDetailContainer() { const serviceCategory = getServiceCategory(data?.orderType); const serviceIcon = renderServiceIcon(serviceCategory, "h-6 w-6"); - const accentTone = statusDescriptor - ? STATUS_ACCENT_TONE_CLASSES[statusDescriptor.tone] - : STATUS_ACCENT_TONE_CLASSES.neutral; const displayItems = useMemo(() => { return buildOrderDisplayItems(data?.itemsSummary); @@ -300,186 +326,164 @@ export function OrderDetailContainer() { {data ? ( <> -
- -
-
-
-
- {serviceIcon} -
-
-

{serviceLabel}

-

- Order #{data.orderNumber || String(data.id).slice(-8)} - {placedDate ? ` • Placed ${placedDate}` : null} -

-
-
-
-
- {totals.monthlyTotal > 0 ? ( -

- {yenFormatter.format(totals.monthlyTotal)} - / month -

- ) : ( -

No monthly charges

- )} - {totals.oneTimeTotal > 0 && ( -

- {yenFormatter.format(totals.oneTimeTotal)} one-time -

- )} -
-
-
- -
-

- Your Services & Products -

-
- {displayItems.length === 0 ? ( -
- No items found on this order. +
+ {/* Header Section */} +
+
+
+
+
+ {serviceIcon}
- ) : ( - displayItems.map(item => { - const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other; - const Icon = categoryConfig.icon; - const categories = Array.from(new Set(item.categories)); - - return ( -
-
-
- -
-
-
-

{item.name}

- {typeof item.quantity === "number" && item.quantity > 1 && ( - ×{item.quantity} - )} - {item.status && ( - - {item.status} - - )} -
-
- {categories.map(category => { - const badge = CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.other; - return ( - - {badge.label} - - ); - })} - {item.isBundle && ( - - Bundle - - )} - {item.included && ( - - Included - - )} -
-
-
-
- {item.charges.map((charge, index) => { - const descriptor = describeCharge(charge); - if (charge.amount > 0) { - return ( -
- - {yenFormatter.format(charge.amount)} - - {descriptor} -
- ); - } - - return ( -
- Included - {descriptor} -
- ); - })} -
-
- ); - }) - )} -
-
- - {showFeeNotice && ( -
-
- -
-

Additional fees may apply

-

- Weekend installation, express setup, or specialised configuration work can - add extra costs. We'll always confirm with you before applying any - additional charges. +

+

{serviceLabel}

+

+ Order #{data.orderNumber || String(data.id).slice(-8)} + {placedDate ? ` • ${placedDate}` : null}

-
- )} - {statusDescriptor && ( -
-
-
-

- Status -

-

- {statusDescriptor.description} -

+ {statusDescriptor && ( +
+ + {statusDescriptor.description} {statusDescriptor.timeline && ( -

- Timeline: + + {statusDescriptor.timeline} -

+ + )} + {statusDescriptor.nextAction && ( + + Next: {statusDescriptor.nextAction} + )}
- -
- {statusDescriptor.nextAction && ( -
-

Next steps

-

{statusDescriptor.nextAction}

+ )} +
+ + {/* Pricing Section */} +
+ {totals.monthlyTotal > 0 && ( +
+

Monthly

+

+ {yenFormatter.format(totals.monthlyTotal)} +

+
+ )} + {totals.oneTimeTotal > 0 && ( +
+

One-Time

+

+ {yenFormatter.format(totals.oneTimeTotal)} +

)}
- )} +
+
+ +
+
+ {/* Status Section */} + {/* Order Items Section */} +
+

Order Details

+
+ {displayItems.length === 0 ? ( +
+ No items found on this order. +
+ ) : ( + displayItems.map((item, itemIndex) => { + const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other; + const Icon = categoryConfig.icon; + const prevItem = itemIndex > 0 ? displayItems[itemIndex - 1] : null; + const showBundleStart = item.isBundle && (!prevItem || !prevItem.isBundle); + const style = getItemVisualStyle(item); + + return ( +
+ {showBundleStart && ( +
+ + Bundled + +
+
+ )} +
+
+
+ +
+
+

+ {item.name} +

+

+ {categoryConfig.label} +

+
+
+
+ {item.charges.map((charge, index) => { + const descriptor = describeCharge(charge); + if (charge.amount > 0) { + return ( +
+ + {yenFormatter.format(charge.amount)} + + {descriptor} +
+ ); + } + + return ( +
+ Included {descriptor} +
+ ); + })} +
+
+
+ ); + }) + )} +
+
+ + {showFeeNotice && ( +
+
+ +
+

Additional fees may apply

+

+ Weekend installation, express setup, or specialised configuration work can + add extra costs. We'll always confirm with you before applying any + additional charges. +

+
+
+
+ )} +
diff --git a/apps/portal/src/features/support/views/NewSupportCaseView.tsx b/apps/portal/src/features/support/views/NewSupportCaseView.tsx index f71e80bf..7f87d703 100644 --- a/apps/portal/src/features/support/views/NewSupportCaseView.tsx +++ b/apps/portal/src/features/support/views/NewSupportCaseView.tsx @@ -10,7 +10,7 @@ import { InformationCircleIcon, } from "@heroicons/react/24/outline"; -import { logger } from "@customer-portal/logging"; +import { logger } from "@/lib/logger"; export function NewSupportCaseView() { const router = useRouter(); diff --git a/apps/portal/src/lib/api/helpers.ts b/apps/portal/src/lib/api/helpers.ts deleted file mode 100644 index 3ddf8706..00000000 --- a/apps/portal/src/lib/api/helpers.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * API Helper Functions - * Generic utilities for working with API responses - */ - -/** - * Extract data from API response or throw error - */ -export function getDataOrThrow( - response: { data?: T; error?: unknown }, - errorMessage: string -): T { - if (response.error || !response.data) { - throw new Error(errorMessage); - } - return response.data; -} - -/** - * Extract data from API response or return default value - */ -export function getDataOrDefault(response: { data?: T; error?: unknown }, defaultValue: T): T { - return response.data ?? defaultValue; -} - -/** - * Check if value is an API error - */ -export function isApiError(error: unknown): error is Error { - return error instanceof Error; -} diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index ded4fec0..911ba003 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -12,7 +12,7 @@ export { ApiError, isApiError } from "./runtime/client"; export * from "./response-helpers"; // Import createClient for internal use -import { createClient } from "./runtime/client"; +import { createClient, ApiError } from "./runtime/client"; import { logger } from "@/lib/logger"; /** @@ -54,7 +54,7 @@ async function handleApiError(response: Response): Promise { // Ignore body parse errors } - throw new (await import("./runtime/client")).ApiError(message, response, body); + throw new ApiError(message, response, body); } export const apiClient = createClient({ diff --git a/apps/portal/src/lib/hooks/useCurrency.ts b/apps/portal/src/lib/hooks/useCurrency.ts index 19ca3c5a..92bfcbac 100644 --- a/apps/portal/src/lib/hooks/useCurrency.ts +++ b/apps/portal/src/lib/hooks/useCurrency.ts @@ -1,10 +1,11 @@ "use client"; import { useState, useEffect } from "react"; -import { currencyService, type CurrencyInfo } from "@/lib/services/currency.service"; +import { currencyService, FALLBACK_CURRENCY } from "@/lib/services/currency.service"; +import type { WhmcsCurrency } from "@customer-portal/domain/billing"; export function useCurrency() { - const [defaultCurrency, setDefaultCurrency] = useState(null); + const [defaultCurrency, setDefaultCurrency] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -18,13 +19,7 @@ export function useCurrency() { } catch (err) { setError(err instanceof Error ? err.message : "Failed to load currency"); // Fallback to JPY if API fails - setDefaultCurrency({ - code: "JPY", - prefix: "¥", - suffix: "", - format: "1", - rate: "1.00000", - }); + setDefaultCurrency(FALLBACK_CURRENCY); } finally { setLoading(false); } diff --git a/apps/portal/src/lib/hooks/useLocalStorage.ts b/apps/portal/src/lib/hooks/useLocalStorage.ts index 51194b78..8241d04a 100644 --- a/apps/portal/src/lib/hooks/useLocalStorage.ts +++ b/apps/portal/src/lib/hooks/useLocalStorage.ts @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import logger from "@customer-portal/logging"; +import { logger } from "@/lib/logger"; /** * Hook for managing localStorage with SSR safety diff --git a/apps/portal/src/lib/logger.ts b/apps/portal/src/lib/logger.ts index ab54560c..a477ce01 100644 --- a/apps/portal/src/lib/logger.ts +++ b/apps/portal/src/lib/logger.ts @@ -74,3 +74,11 @@ class Logger { } export const logger = new Logger(); + +export const log = { + info: (message: string, meta?: LogMeta) => logger.info(message, meta), + warn: (message: string, meta?: LogMeta) => logger.warn(message, meta), + error: (message: string, error?: unknown, meta?: LogMeta) => + logger.error(message, error, meta), + debug: (message: string, meta?: LogMeta) => logger.debug(message, meta), +}; diff --git a/apps/portal/src/lib/services/currency.service.ts b/apps/portal/src/lib/services/currency.service.ts index 58141cd0..3e8f9a7c 100644 --- a/apps/portal/src/lib/services/currency.service.ts +++ b/apps/portal/src/lib/services/currency.service.ts @@ -1,34 +1,23 @@ -import { apiClient } from "@/lib/api"; +import { apiClient, getDataOrThrow } from "@/lib/api"; +import type { WhmcsCurrency } from "@customer-portal/domain/billing"; -export interface CurrencyInfo { - code: string; - prefix: string; - suffix: string; - format: string; - rate: string; -} +export const FALLBACK_CURRENCY: WhmcsCurrency = { + id: 1, + code: "JPY", + prefix: "¥", + suffix: "", + format: "1", + rate: "1.00000", +}; -export interface CurrencyService { - getDefaultCurrency(): Promise; - getAllCurrencies(): Promise; -} +export const currencyService = { + async getDefaultCurrency(): Promise { + const response = await apiClient.GET("/api/currency/default"); + return getDataOrThrow(response, "Failed to get default currency"); + }, -class CurrencyServiceImpl implements CurrencyService { - async getDefaultCurrency(): Promise { - const response = await apiClient.GET("/api/currency/default"); - if (!response.data) { - throw new Error("Failed to get default currency"); - } - return response.data as CurrencyInfo; - } - - async getAllCurrencies(): Promise { - const response = await apiClient.GET("/api/currency/all"); - if (!response.data) { - throw new Error("Failed to get currencies"); - } - return response.data as CurrencyInfo[]; - } -} - -export const currencyService = new CurrencyServiceImpl(); + async getAllCurrencies(): Promise { + const response = await apiClient.GET("/api/currency/all"); + return getDataOrThrow(response, "Failed to get currencies"); + }, +}; diff --git a/apps/portal/tsconfig.json b/apps/portal/tsconfig.json index de7907e9..6b35b98e 100644 --- a/apps/portal/tsconfig.json +++ b/apps/portal/tsconfig.json @@ -18,8 +18,6 @@ "@/lib/*": ["./src/lib/*"], "@customer-portal/domain": ["../../packages/domain/index.ts"], "@customer-portal/domain/*": ["../../packages/domain/*"], - "@customer-portal/logging": ["../../packages/logging/src"], - "@customer-portal/logging/*": ["../../packages/logging/src/*"], "@customer-portal/validation": ["../../packages/validation/src"], "@customer-portal/validation/*": ["../../packages/validation/src/*"] }, diff --git a/env/portal-backend.env.sample b/env/portal-backend.env.sample index af4f6b67..fcbef350 100644 --- a/env/portal-backend.env.sample +++ b/env/portal-backend.env.sample @@ -25,6 +25,12 @@ JWT_SECRET=CHANGE_ME JWT_EXPIRES_IN=7d BCRYPT_ROUNDS=12 +# CSRF Protection +CSRF_TOKEN_EXPIRY=3600000 +CSRF_SECRET_KEY=CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG +CSRF_COOKIE_NAME=csrf-secret +CSRF_HEADER_NAME=X-CSRF-Token + # CORS / Proxy CORS_ORIGIN=https://asolutions.jp TRUST_PROXY=true @@ -34,6 +40,21 @@ RATE_LIMIT_TTL=60 RATE_LIMIT_LIMIT=100 AUTH_RATE_LIMIT_TTL=900 AUTH_RATE_LIMIT_LIMIT=3 +AUTH_REFRESH_RATE_LIMIT_TTL=300 +AUTH_REFRESH_RATE_LIMIT_LIMIT=10 +LOGIN_RATE_LIMIT_TTL=900 +LOGIN_RATE_LIMIT_LIMIT=5 +LOGIN_CAPTCHA_AFTER_ATTEMPTS=3 +SIGNUP_RATE_LIMIT_TTL=900 +SIGNUP_RATE_LIMIT_LIMIT=5 +PASSWORD_RESET_RATE_LIMIT_TTL=900 +PASSWORD_RESET_RATE_LIMIT_LIMIT=5 + +# CAPTCHA Configuration +AUTH_CAPTCHA_PROVIDER=none +AUTH_CAPTCHA_SECRET= +AUTH_CAPTCHA_THRESHOLD=0 +AUTH_CAPTCHA_ALWAYS_ON=false # Validation error visibility (set true to show field-level errors to clients) EXPOSE_VALIDATION_ERRORS=false diff --git a/packages/domain/package.json b/packages/domain/package.json index b1debe75..a2962ba6 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -35,6 +35,7 @@ }, "scripts": { "build": "tsc", + "dev": "tsc -w --preserveWatchOutput", "clean": "rm -rf dist", "type-check": "NODE_OPTIONS=\"--max-old-space-size=2048 --max-semi-space-size=128\" tsc --project tsconfig.json --noEmit", "typecheck": "pnpm run type-check" diff --git a/scripts/dev/manage.sh b/scripts/dev/manage.sh index 5b39de6c..133d4b3d 100755 --- a/scripts/dev/manage.sh +++ b/scripts/dev/manage.sh @@ -357,7 +357,11 @@ start_apps() { # Prisma Studio can be started manually with: pnpm db:studio # Run portal + bff in parallel with hot reload - pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev + pnpm --parallel \ + --filter @customer-portal/domain \ + --filter @customer-portal/validation \ + --filter @customer-portal/portal \ + --filter @customer-portal/bff run dev } reset_env() {