From 74d469ce223bcea1069b84ca26ca024269e674b7 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 29 Dec 2025 17:17:36 +0900 Subject: [PATCH] Refactor API Client Error Handling and Update Date Formatting - Enhanced error handling in various API client services by integrating `redactForLogs` for sensitive data protection in logs. - Updated date formatting across multiple components and services to utilize `formatIsoDate` and `formatIsoRelative`, improving consistency and readability. - Removed unused imports and optimized code structure for better maintainability. - Streamlined retry logic in the Freebit and WHMCS services to improve resilience and error management during API requests. --- .github/actions/setup-node-pnpm/action.yml | 6 - apps/bff/src/core/logging/redaction.util.ts | 109 ++++++ .../services/whmcs-request-queue.service.ts | 9 +- .../services/freebit-client.service.ts | 344 ++++++++---------- .../services/whmcs-http-client.service.ts | 129 +------ .../src/features/auth/services/auth.store.ts | 15 +- .../InvoiceDetail/InvoiceHeader.tsx | 11 +- .../InvoiceDetail/InvoiceSummaryBar.tsx | 16 +- .../components/InvoiceTable/InvoiceTable.tsx | 9 +- .../dashboard/components/ActivityTimeline.tsx | 4 +- .../marketing/views/PublicLandingView.tsx | 188 ---------- .../components/NotificationItem.tsx | 7 +- .../features/orders/hooks/useOrderUpdates.ts | 19 +- .../features/orders/utils/order-display.ts | 12 +- .../features/orders/utils/order-presenters.ts | 14 +- .../src/features/orders/utils/summary.ts | 17 + .../src/features/orders/views/OrderDetail.tsx | 24 +- .../components/sim/SimConfigureView.tsx | 3 +- .../features/services/views/InternetPlans.tsx | 4 +- .../sim/components/DataUsageChart.tsx | 6 +- .../sim/components/SimDetailsCard.tsx | 13 +- .../sim/components/SimManagementSection.tsx | 11 +- .../components/SubscriptionCard.tsx | 10 +- .../components/SubscriptionDetails.tsx | 10 +- .../SubscriptionTable/SubscriptionTable.tsx | 10 +- .../views/SubscriptionDetail.tsx | 11 +- .../support/views/SupportCaseDetailView.tsx | 11 +- .../support/views/SupportCasesView.tsx | 5 +- .../support/views/SupportHomeView.tsx | 4 +- apps/portal/src/lib/api/index.ts | 47 +-- apps/portal/src/lib/api/runtime/client.ts | 18 +- .../src/lib/api/runtime/error-message.ts | 35 ++ apps/portal/src/lib/api/unauthorized.ts | 33 ++ apps/portal/src/lib/utils/date.ts | 72 ++++ apps/portal/src/lib/utils/index.ts | 9 + 35 files changed, 550 insertions(+), 695 deletions(-) create mode 100644 apps/bff/src/core/logging/redaction.util.ts delete mode 100644 apps/portal/src/features/marketing/views/PublicLandingView.tsx create mode 100644 apps/portal/src/features/orders/utils/summary.ts create mode 100644 apps/portal/src/lib/api/runtime/error-message.ts create mode 100644 apps/portal/src/lib/api/unauthorized.ts create mode 100644 apps/portal/src/lib/utils/date.ts diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml index 317754ed..55453446 100644 --- a/.github/actions/setup-node-pnpm/action.yml +++ b/.github/actions/setup-node-pnpm/action.yml @@ -6,18 +6,12 @@ inputs: description: Node.js version to use required: false default: "22" - pnpm-version: - description: pnpm version to use - required: false - default: "10.25.0" runs: using: composite steps: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: ${{ inputs.pnpm-version }} - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/apps/bff/src/core/logging/redaction.util.ts b/apps/bff/src/core/logging/redaction.util.ts new file mode 100644 index 00000000..108f0ab3 --- /dev/null +++ b/apps/bff/src/core/logging/redaction.util.ts @@ -0,0 +1,109 @@ +/** + * Log redaction utilities (BFF) + * + * Keep logs production-safe by redacting common sensitive fields and trimming large blobs. + * Prefer allowlists at call sites for best safety; this helper provides a safe baseline. + */ + +export type RedactOptions = { + /** + * Extra keys to redact (case-insensitive, substring match). + * Example: ["email", "address"] + */ + extraSensitiveKeys?: readonly string[]; + /** Max string length to keep (defaults to 500). */ + maxStringLength?: number; + /** Max depth to traverse (defaults to 6). */ + maxDepth?: number; +}; + +const DEFAULT_SENSITIVE_KEY_PARTS = [ + "password", + "passwd", + "secret", + "token", + "key", + "auth", + "authorization", + "cookie", + "set-cookie", + "session", + "sid", + "credit", + "card", + "cvv", + "cvc", + "ssn", + "social", + "email", + "phone", + "address", +] as const; + +const REDACTED = "[REDACTED]"; +const TRUNCATED = "[TRUNCATED]"; + +function shouldRedactKey(key: string, extra: readonly string[]): boolean { + const lower = key.toLowerCase(); + for (const part of DEFAULT_SENSITIVE_KEY_PARTS) { + if (lower.includes(part)) return true; + } + for (const part of extra) { + if (lower.includes(part.toLowerCase())) return true; + } + return false; +} + +function truncateString(value: string, max: number): string { + if (value.length <= max) return value; + return value.slice(0, max) + TRUNCATED; +} + +export function redactForLogs(value: T, options: RedactOptions = {}): T { + const maxStringLength = options.maxStringLength ?? 500; + const maxDepth = options.maxDepth ?? 6; + const extraSensitiveKeys = options.extraSensitiveKeys ?? []; + + const seen = new WeakSet(); + + const walk = (input: unknown, depth: number): unknown => { + if (input === null || input === undefined) return input; + if (depth > maxDepth) return "[MAX_DEPTH]"; + + if (typeof input === "string") { + return truncateString(input, maxStringLength); + } + if (typeof input === "number" || typeof input === "boolean" || typeof input === "bigint") { + return input; + } + if (typeof input === "symbol") { + return input.description ? `Symbol(${input.description})` : "Symbol()"; + } + if (typeof input === "function") { + return input.name ? `[Function ${input.name}]` : "[Function anonymous]"; + } + if (input instanceof Date) { + return input.toISOString(); + } + if (Array.isArray(input)) { + return input.map(entry => walk(entry, depth + 1)); + } + if (typeof input === "object") { + if (seen.has(input)) return "[CIRCULAR]"; + seen.add(input); + + const record = input as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(record)) { + out[k] = shouldRedactKey(k, extraSensitiveKeys) ? REDACTED : walk(v, depth + 1); + } + return out; + } + + // Avoid implicit Object stringification like "[object Object]" when `input` is still `unknown` + // to TypeScript/ESLint (even though the runtime cases above cover all standard JS types). + return Object.prototype.toString.call(input); + }; + + return walk(value, 0) as T; +} diff --git a/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts b/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts index 1fc7a999..e428f741 100644 --- a/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts +++ b/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts @@ -2,7 +2,7 @@ import { Injectable, Inject } from "@nestjs/common"; import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; -import { withRetry } from "@bff/core/utils/retry.util.js"; +import { RetryableErrors, withRetry } from "@bff/core/utils/retry.util.js"; import { generateRequestId } from "@bff/core/logging/request-id.util.js"; type PQueueCtor = new (options: { @@ -52,8 +52,10 @@ export interface WhmcsRequestOptions { * * Based on research: * - WHMCS has no official rate limits but performance degrades with high concurrency - * - Conservative approach: max 3 concurrent requests - * - Rate limiting: max 30 requests per minute (0.5 RPS) + * - Defaults here are intentionally configurable via env: + * - WHMCS_QUEUE_CONCURRENCY (default: 15) + * - WHMCS_QUEUE_INTERVAL_CAP (default: 300 per minute) + * - WHMCS_QUEUE_TIMEOUT_MS (default: 30000) */ @Injectable() export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { @@ -290,6 +292,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { return withRetry(requestFn, { maxAttempts: options.retryAttempts ?? 3, baseDelayMs: options.retryDelay ?? 1000, + isRetryable: error => RetryableErrors.isTransientError(error), logger: this.logger, logContext: "WHMCS request", }); diff --git a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts index 9bc972f5..f8abfa99 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -1,6 +1,8 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { RetryableErrors, withRetry } from "@bff/core/utils/retry.util.js"; +import { redactForLogs } from "@bff/core/logging/redaction.util.js"; import { FreebitAuthService } from "./freebit-auth.service.js"; import { FreebitError } from "./freebit-error.service.js"; @@ -26,110 +28,98 @@ export class FreebitClientService { endpoint: string, payload: TPayload ): Promise { - const authKey = await this.authService.getAuthKey(); const config = this.authService.getConfig(); + const authKey = await this.authService.getAuthKey(); + const url = this.buildUrl(config.baseUrl, endpoint); const requestPayload = { ...payload, authKey }; - // Ensure proper URL construction - remove double slashes - const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash - const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; - const url = `${baseUrl}${cleanEndpoint}`; - for (let attempt = 1; attempt <= config.retryAttempts; attempt++) { - try { - this.logger.debug(`Freebit API request (attempt ${attempt}/${config.retryAttempts})`, { + let attempt = 0; + return withRetry( + async () => { + attempt += 1; + + this.logger.debug(`Freebit API request`, { url, - payload: this.sanitizePayload(requestPayload), + attempt, + maxAttempts: config.retryAttempts, + payload: redactForLogs(requestPayload), }); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), config.timeout); - - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `json=${JSON.stringify(requestPayload)}`, - signal: controller.signal, - }); - - clearTimeout(timeout); - - if (!response.ok) { - const errorText = await response.text().catch(() => "Unable to read response body"); - this.logger.error(`Freebit API HTTP error`, { - url, - status: response.status, - statusText: response.statusText, - responseBody: errorText, - attempt, - payload: this.sanitizePayload(requestPayload), - }); - throw new FreebitError( - `HTTP ${response.status}: ${response.statusText}`, - response.status.toString() - ); - } - - const responseData = (await response.json()) as TResponse; - - const resultCode = this.normalizeResultCode(responseData.resultCode); - const statusCode = this.normalizeResultCode(responseData.status?.statusCode); - - if (resultCode && resultCode !== "100") { - this.logger.warn("Freebit API returned error response", { - url, - resultCode, - statusCode, - statusMessage: responseData.status?.message, - fullResponse: responseData, + const timeoutId = setTimeout(() => controller.abort(), config.timeout); + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `json=${JSON.stringify(requestPayload)}`, + signal: controller.signal, }); - throw new FreebitError( - `API Error: ${responseData.status?.message || "Unknown error"}`, - resultCode, - statusCode, - responseData.status?.message - ); - } - - this.logger.debug("Freebit API request successful", { - url, - resultCode, - }); - - return responseData; - } catch (error: unknown) { - if (error instanceof FreebitError) { - if (error.isAuthError() && attempt === 1) { - this.logger.warn("Auth error detected, clearing cache and retrying"); - this.authService.clearAuthCache(); - continue; + if (!response.ok) { + const isProd = process.env.NODE_ENV === "production"; + const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response); + this.logger.error("Freebit API HTTP error", { + url, + status: response.status, + statusText: response.statusText, + ...(bodySnippet ? { responseBodySnippet: bodySnippet } : {}), + attempt, + }); + throw new FreebitError( + `HTTP ${response.status}: ${response.statusText}`, + response.status.toString() + ); } - if (!error.isRetryable() || attempt === config.retryAttempts) { - throw error; + + const responseData = (await response.json()) as TResponse; + + const resultCode = this.normalizeResultCode(responseData.resultCode); + const statusCode = this.normalizeResultCode(responseData.status?.statusCode); + + if (resultCode && resultCode !== "100") { + const isProd = process.env.NODE_ENV === "production"; + this.logger.warn("Freebit API returned error response", { + url, + resultCode, + statusCode, + statusMessage: responseData.status?.message, + ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), + }); + + throw new FreebitError( + `API Error: ${responseData.status?.message || "Unknown error"}`, + resultCode, + statusCode, + responseData.status?.message + ); } - } - if (attempt === config.retryAttempts) { - const message = extractErrorMessage(error); - this.logger.error(`Freebit API request failed after ${config.retryAttempts} attempts`, { - url, - error: message, - }); - throw new FreebitError(`Request failed: ${message}`); + this.logger.debug("Freebit API request successful", { url, resultCode }); + return responseData; + } finally { + clearTimeout(timeoutId); } - - const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000); - this.logger.warn(`Freebit API request failed, retrying in ${delay}ms`, { - url, - attempt, - error: extractErrorMessage(error), - }); - await new Promise(resolve => setTimeout(resolve, delay)); + }, + { + maxAttempts: config.retryAttempts, + baseDelayMs: 1000, + maxDelayMs: 10000, + isRetryable: error => { + if (error instanceof FreebitError) { + if (error.isAuthError() && attempt === 1) { + this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url }); + this.authService.clearAuthCache(); + return true; + } + return error.isRetryable(); + } + return RetryableErrors.isTransientError(error); + }, + logger: this.logger, + logContext: "Freebit API request", } - } - - throw new FreebitError("Request failed after all retry attempts"); + ); } /** @@ -140,100 +130,84 @@ export class FreebitClientService { TPayload extends object, >(endpoint: string, payload: TPayload): Promise { const config = this.authService.getConfig(); - // Ensure proper URL construction - remove double slashes - const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash - const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; - const url = `${baseUrl}${cleanEndpoint}`; + const url = this.buildUrl(config.baseUrl, endpoint); - for (let attempt = 1; attempt <= config.retryAttempts; attempt++) { - try { - this.logger.debug(`Freebit JSON API request (attempt ${attempt}/${config.retryAttempts})`, { + let attempt = 0; + return withRetry( + async () => { + attempt += 1; + this.logger.debug("Freebit JSON API request", { url, - payload: this.sanitizePayload(payload as Record), + attempt, + maxAttempts: config.retryAttempts, + payload: redactForLogs(payload), }); const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), config.timeout); - - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - signal: controller.signal, - }); - - clearTimeout(timeout); - - if (!response.ok) { - throw new FreebitError( - `HTTP ${response.status}: ${response.statusText}`, - response.status.toString() - ); - } - - const responseData = (await response.json()) as TResponse; - - const resultCode = this.normalizeResultCode(responseData.resultCode); - const statusCode = this.normalizeResultCode(responseData.status?.statusCode); - - if (resultCode && resultCode !== "100") { - this.logger.error(`Freebit API returned error result code`, { - url, - resultCode, - statusCode, - message: responseData.status?.message, - responseData: this.sanitizePayload(responseData as unknown as Record), - attempt, + const timeoutId = setTimeout(() => controller.abort(), config.timeout); + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, }); - throw new FreebitError( - `API Error: ${responseData.status?.message || "Unknown error"}`, - resultCode, - statusCode, - responseData.status?.message - ); - } - this.logger.debug("Freebit JSON API request successful", { - url, - resultCode, - }); - - return responseData; - } catch (error: unknown) { - if (error instanceof FreebitError) { - if (error.isAuthError() && attempt === 1) { - this.logger.warn("Auth error detected, clearing cache and retrying"); - this.authService.clearAuthCache(); - continue; + if (!response.ok) { + throw new FreebitError( + `HTTP ${response.status}: ${response.statusText}`, + response.status.toString() + ); } - if (!error.isRetryable() || attempt === config.retryAttempts) { - throw error; - } - } - if (attempt === config.retryAttempts) { - const message = extractErrorMessage(error); - this.logger.error( - `Freebit JSON API request failed after ${config.retryAttempts} attempts`, - { + const responseData = (await response.json()) as TResponse; + + const resultCode = this.normalizeResultCode(responseData.resultCode); + const statusCode = this.normalizeResultCode(responseData.status?.statusCode); + + if (resultCode && resultCode !== "100") { + const isProd = process.env.NODE_ENV === "production"; + this.logger.error("Freebit API returned error result code", { url, - error: message, - } - ); - throw new FreebitError(`Request failed: ${message}`); + resultCode, + statusCode, + message: responseData.status?.message, + ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), + attempt, + }); + throw new FreebitError( + `API Error: ${responseData.status?.message || "Unknown error"}`, + resultCode, + statusCode, + responseData.status?.message + ); + } + + this.logger.debug("Freebit JSON API request successful", { url, resultCode }); + return responseData; + } finally { + clearTimeout(timeoutId); } - - const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000); - this.logger.warn(`Freebit JSON API request failed, retrying in ${delay}ms`, { - url, - attempt, - error: extractErrorMessage(error), - }); - await new Promise(resolve => setTimeout(resolve, delay)); + }, + { + maxAttempts: config.retryAttempts, + baseDelayMs: 1000, + maxDelayMs: 10000, + isRetryable: error => { + if (error instanceof FreebitError) { + if (error.isAuthError() && attempt === 1) { + this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url }); + this.authService.clearAuthCache(); + return true; + } + return error.isRetryable(); + } + return RetryableErrors.isTransientError(error); + }, + logger: this.logger, + logContext: "Freebit JSON API request", } - } - - throw new FreebitError("Request failed after all retry attempts"); + ); } /** @@ -241,10 +215,7 @@ export class FreebitClientService { */ async makeSimpleRequest(endpoint: string): Promise { const config = this.authService.getConfig(); - // Ensure proper URL construction - remove double slashes - const baseUrl = config.baseUrl.replace(/\/$/, ""); // Remove trailing slash - const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; - const url = `${baseUrl}${cleanEndpoint}`; + const url = this.buildUrl(config.baseUrl, endpoint); try { const controller = new AbortController(); @@ -268,20 +239,21 @@ export class FreebitClientService { } /** - * Sanitize payload for logging (remove sensitive data) + * Ensure proper URL construction - remove double slashes */ - private sanitizePayload(payload: Record): Record { - const sanitized = { ...payload }; + private buildUrl(baseUrl: string, endpoint: string): string { + const cleanBase = baseUrl.replace(/\/$/, ""); + const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; + return `${cleanBase}${cleanEndpoint}`; + } - // Remove sensitive fields - const sensitiveFields = ["authKey", "oemKey", "password", "secret"]; - for (const field of sensitiveFields) { - if (sanitized[field]) { - sanitized[field] = "[REDACTED]"; - } + private async safeReadBodySnippet(response: Response): Promise { + try { + const text = await response.text(); + return text ? text.slice(0, 300) : undefined; + } catch { + return undefined; } - - return sanitized; } private normalizeResultCode(code?: string | number | null): string | undefined { diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts index a603e4f4..9acac278 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts @@ -1,6 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { redactForLogs } from "@bff/core/logging/redaction.util.js"; import type { WhmcsResponse } from "@customer-portal/domain/common/providers"; import type { WhmcsApiConfig, @@ -50,7 +51,7 @@ export class WhmcsHttpClientService { this.logger.error(`WHMCS HTTP request failed [${action}]`, { error: extractErrorMessage(error), action, - params: this.sanitizeLogParams(params), + params: redactForLogs(params), responseTime: Date.now() - startTime, }); @@ -78,7 +79,11 @@ export class WhmcsHttpClientService { } /** - * Execute the actual HTTP request with retry logic + * Execute the actual HTTP request. + * + * NOTE: Retries are handled at the queue/orchestrator level (`WhmcsRequestQueueService`) + * to avoid nested retries (retry storms) and to keep concurrency/rate-limit behavior + * centralized. */ private async executeRequest( config: WhmcsApiConfig, @@ -86,37 +91,7 @@ export class WhmcsHttpClientService { params: Record, options: WhmcsRequestOptions ): Promise> { - const maxAttempts = options.retryAttempts ?? config.retryAttempts ?? 3; - let lastError: Error; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await this.performSingleRequest(config, action, params, options); - } catch (error) { - lastError = error as Error; - - if (attempt === maxAttempts) { - break; - } - - // Don't retry on certain error types - if (this.shouldNotRetry(error)) { - break; - } - - const delay = this.calculateRetryDelay(attempt, config.retryDelay ?? 1000); - this.logger.warn(`WHMCS request failed, retrying in ${delay}ms`, { - action, - attempt, - maxAttempts, - error: extractErrorMessage(error), - }); - - await this.sleep(delay); - } - } - - throw lastError!; + return this.performSingleRequest(config, action, params, options); } /** @@ -259,20 +234,26 @@ export class WhmcsHttpClientService { try { parsedResponse = JSON.parse(responseText); } catch (parseError) { + const isProd = process.env.NODE_ENV === "production"; this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, { - responseText: responseText.substring(0, 500), + ...(isProd + ? { responseTextLength: responseText.length } + : { responseText: responseText.substring(0, 500) }), parseError: extractErrorMessage(parseError), - params: this.sanitizeLogParams(params), + params: redactForLogs(params), }); throw new Error("Invalid JSON response from WHMCS API"); } // Validate basic response structure if (!this.isWhmcsResponse(parsedResponse)) { + const isProd = process.env.NODE_ENV === "production"; this.logger.error(`WHMCS API returned invalid response structure [${action}]`, { responseType: typeof parsedResponse, - responseText: responseText.substring(0, 500), - params: this.sanitizeLogParams(params), + ...(isProd + ? { responseTextLength: responseText.length } + : { responseText: responseText.substring(0, 500) }), + params: redactForLogs(params), }); throw new Error("Invalid response structure from WHMCS API"); } @@ -291,7 +272,7 @@ export class WhmcsHttpClientService { this.logger.warn(`WHMCS API returned error [${action}]`, { errorMessage, errorCode, - params: this.sanitizeLogParams(params), + params: redactForLogs(params), }); // Return error response for the orchestrator to handle with proper exception types @@ -345,46 +326,6 @@ export class WhmcsHttpClientService { return fallback; } - /** - * Check if error should not be retried - */ - private shouldNotRetry(error: unknown): boolean { - const message = extractErrorMessage(error).toLowerCase(); - - // Don't retry authentication errors - if (message.includes("authentication") || message.includes("unauthorized")) { - return true; - } - - // Don't retry validation errors - if (message.includes("invalid") || message.includes("required")) { - return true; - } - - // Don't retry not found errors - if (message.includes("not found")) { - return true; - } - - return false; - } - - /** - * Calculate retry delay with exponential backoff - */ - private calculateRetryDelay(attempt: number, baseDelay: number): number { - const exponentialDelay = baseDelay * Math.pow(2, attempt - 1); - const jitter = Math.random() * 0.1 * exponentialDelay; - return Math.min(exponentialDelay + jitter, 10000); // Max 10 seconds - } - - /** - * Sleep for specified milliseconds - */ - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - /** * Update success statistics */ @@ -396,36 +337,4 @@ export class WhmcsHttpClientService { this.stats.averageResponseTime = (this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful; } - - /** - * Sanitize parameters for logging (remove sensitive data) - */ - private sanitizeLogParams(params: Record): Record { - const sensitiveKeys = [ - "password", - "secret", - "token", - "key", - "auth", - "credit_card", - "cvv", - "ssn", - "social_security", - ]; - - const sanitized: Record = {}; - - for (const [key, value] of Object.entries(params)) { - const keyLower = key.toLowerCase(); - const isSensitive = sensitiveKeys.some(sensitive => keyLower.includes(sensitive)); - - if (isSensitive) { - sanitized[key] = "[REDACTED]"; - } else { - sanitized[key] = value; - } - } - - return sanitized; - } } diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 11808afd..693beee9 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -8,6 +8,7 @@ import { apiClient } from "@/lib/api"; import { getNullableData } from "@/lib/api/response-helpers"; import { parseError } from "@/lib/utils/error-handling"; import { logger } from "@/lib/logger"; +import { onUnauthorized } from "@/lib/api"; import { authResponseSchema, checkPasswordNeededResponseSchema, @@ -62,6 +63,8 @@ type AuthResponseData = { session: AuthSession; }; +let unauthorizedSubscriptionInitialized = false; + export const useAuthStore = create()((set, get) => { const applyAuthResponse = (data: AuthResponseData, keepLoading = false) => { set({ @@ -109,12 +112,12 @@ export const useAuthStore = create()((set, get) => { await refreshPromise; }; - // Set up global listener for 401 errors from API client - if (typeof window !== "undefined") { - type AuthUnauthorizedDetail = { url?: string; status?: number }; - window.addEventListener("auth:unauthorized", event => { - const customEvent = event as CustomEvent; - const detail = customEvent.detail; + // Set up global listener for 401 errors from API client. + // This is intentionally NOT a DOM event to keep it testable and SSR-safe. + // Guard to prevent duplicate subscriptions during dev/HMR. + if (!unauthorizedSubscriptionInitialized) { + unauthorizedSubscriptionInitialized = true; + onUnauthorized(detail => { logger.warn("401 Unauthorized detected - triggering logout", { url: detail?.url, status: detail?.status, diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx index cadd707c..8e856d42 100644 --- a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx @@ -3,22 +3,17 @@ import React from "react"; import { Skeleton } from "@/components/atoms/loading-skeleton"; import { ArrowTopRightOnSquareIcon, ArrowDownTrayIcon } from "@heroicons/react/24/outline"; -import { format } from "date-fns"; import type { Invoice } from "@customer-portal/domain/billing"; import { Formatting } from "@customer-portal/domain/toolkit"; +import { formatIsoDate } from "@/lib/utils"; const { formatCurrency } = Formatting; const formatDate = (dateString?: string) => { if (!dateString || dateString === "0000-00-00" || dateString === "0000-00-00 00:00:00") return "N/A"; - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) return "N/A"; - return format(date, "MMM d, yyyy"); - } catch { - return "N/A"; - } + const formatted = formatIsoDate(dateString, { fallback: "N/A" }); + return formatted === "Invalid date" ? "N/A" : formatted; }; interface InvoiceHeaderProps { diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx index 0a9da2f0..5e444dff 100644 --- a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx @@ -1,5 +1,4 @@ import { useMemo } from "react"; -import { format, formatDistanceToNowStrict } from "date-fns"; import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; import { Formatting } from "@customer-portal/domain/toolkit"; @@ -7,7 +6,7 @@ const { formatCurrency } = Formatting; import type { Invoice } from "@customer-portal/domain/billing"; import { Button } from "@/components/atoms/button"; import { StatusPill } from "@/components/atoms/status-pill"; -import { cn } from "@/lib/utils"; +import { cn, formatIsoDate, formatIsoRelative } from "@/lib/utils"; interface InvoiceSummaryBarProps { invoice: Invoice; @@ -43,9 +42,8 @@ const statusLabelMap: Partial> = { function formatDisplayDate(dateString?: string) { if (!dateString) return null; - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) return null; - return format(date, "dd MMM yyyy"); + const formatted = formatIsoDate(dateString); + return formatted === "N/A" || formatted === "Invalid date" ? null : formatted; } function formatRelativeDue( @@ -59,10 +57,10 @@ function formatRelativeDue( if (status === "Overdue" && daysOverdue) { return `${daysOverdue} day${daysOverdue !== 1 ? "s" : ""} overdue`; } else if (status === "Unpaid") { - const dueDate = new Date(dateString); - if (Number.isNaN(dueDate.getTime())) return null; - const distance = formatDistanceToNowStrict(dueDate); - return `due in ${distance}`; + const relative = formatIsoRelative(dateString); + if (relative === "N/A" || relative === "Invalid date") return null; + // Relative formatter already yields "in X ..." for future dates. + return relative.startsWith("in ") ? `due ${relative}` : `due ${relative}`; } return null; diff --git a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx index 0207bcd7..90efde09 100644 --- a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx +++ b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx @@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import { format } from "date-fns"; import { DocumentTextIcon, CheckCircleIcon, @@ -17,7 +16,7 @@ import type { Invoice } from "@customer-portal/domain/billing"; import { Formatting } from "@customer-portal/domain/toolkit"; const { formatCurrency } = Formatting; -import { cn } from "@/lib/utils"; +import { cn, formatIsoDate } from "@/lib/utils"; import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling"; import { openSsoLink } from "@/features/billing/utils/sso"; import { logger } from "@/lib/logger"; @@ -129,7 +128,7 @@ export function InvoiceTable({ )} {!compact && invoice.issuedAt && (
- Issued {format(new Date(invoice.issuedAt), "MMM d, yyyy")} + Issued {formatIsoDate(invoice.issuedAt)}
)} @@ -152,7 +151,7 @@ export function InvoiceTable({ {invoice.paidDate && (
- {format(new Date(invoice.paidDate), "MMM d, yyyy")} + {formatIsoDate(invoice.paidDate)}
)} @@ -180,7 +179,7 @@ export function InvoiceTable({ {invoice.dueDate && (
- Due {format(new Date(invoice.dueDate), "MMM d, yyyy")} + Due {formatIsoDate(invoice.dueDate)}
)} diff --git a/apps/portal/src/features/dashboard/components/ActivityTimeline.tsx b/apps/portal/src/features/dashboard/components/ActivityTimeline.tsx index 7dd3825a..945e50f5 100644 --- a/apps/portal/src/features/dashboard/components/ActivityTimeline.tsx +++ b/apps/portal/src/features/dashboard/components/ActivityTimeline.tsx @@ -1,10 +1,10 @@ "use client"; import { useMemo } from "react"; -import { format, isToday, isYesterday, isSameDay } from "date-fns"; import { ArrowTrendingUpIcon } from "@heroicons/react/24/outline"; import type { Activity } from "@customer-portal/domain/dashboard"; import { DashboardActivityItem } from "./DashboardActivityItem"; +import { isSameDay, isToday, isYesterday } from "@/lib/utils"; interface ActivityTimelineProps { activities: Activity[]; @@ -25,7 +25,7 @@ function formatDateLabel(date: Date): string { if (isYesterday(date)) { return "Yesterday"; } - return format(date, "MMM d"); + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); } function groupActivitiesByDate(activities: Activity[]): GroupedActivities[] { diff --git a/apps/portal/src/features/marketing/views/PublicLandingView.tsx b/apps/portal/src/features/marketing/views/PublicLandingView.tsx deleted file mode 100644 index 6f3e0186..00000000 --- a/apps/portal/src/features/marketing/views/PublicLandingView.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import Link from "next/link"; -import { Logo } from "@/components/atoms/logo"; -import { - UserIcon, - SparklesIcon, - CreditCardIcon, - Cog6ToothIcon, - PhoneIcon, -} from "@heroicons/react/24/outline"; - -export function PublicLandingView() { - return ( -
- {/* Header */} -
-
-
-
- - Assist Solutions -
- -
- - Login - -
-
-
-
- - {/* Hero Section */} -
- {/* Subtle background decoration */} -
-
-
-
- -
-

- Customer Portal -

-

- Manage your services, billing, and support in one place -

-
-
- - {/* Access Options */} -
-
-
- {/* Existing Users */} -
-
-
- -
-

Existing Customers

-

- Access your account or migrate from the old system -

-
- - Login to Portal - - - Migrate Account - -
-
-
- - {/* New Users */} -
-
-
- -
-

New Customers

-

Create an account to get started

- - Create Account - -
-
-
-
-
- - {/* Key Features */} -
-
-
-

- Everything you need -

-
- -
-
-
- -
-

Billing

-

Automated invoicing and payments

-
- -
-
- -
-

Services

-

Manage all your subscriptions

-
- -
-
- -
-

Support

-

Get help when you need it

-
-
-
-
- - {/* Support Section */} -
-
-

- Need help? -

-

- Our support team is here to assist you with any questions -

- - Contact Support - -
-
- - {/* Footer */} -
-
-
-
- - Assist Solutions -
- -
- - Support - - - Privacy - - - Terms - -
-
- -
-

© {new Date().getFullYear()} Assist Solutions. All rights reserved.

-
-
-
-
- ); -} diff --git a/apps/portal/src/features/notifications/components/NotificationItem.tsx b/apps/portal/src/features/notifications/components/NotificationItem.tsx index e23198e9..78ccc9df 100644 --- a/apps/portal/src/features/notifications/components/NotificationItem.tsx +++ b/apps/portal/src/features/notifications/components/NotificationItem.tsx @@ -2,7 +2,6 @@ import { memo, useCallback } from "react"; import Link from "next/link"; -import { formatDistanceToNow } from "date-fns"; import { XMarkIcon } from "@heroicons/react/24/outline"; import { CheckCircleIcon, @@ -11,7 +10,7 @@ import { } from "@heroicons/react/24/solid"; import type { Notification } from "@customer-portal/domain/notifications"; import { NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; -import { cn } from "@/lib/utils"; +import { cn, formatIsoRelative } from "@/lib/utils"; interface NotificationItemProps { notification: Notification; @@ -80,9 +79,7 @@ export const NotificationItem = memo(function NotificationItem({

{notification.message}

)}

- {formatDistanceToNow(new Date(notification.createdAt), { - addSuffix: true, - })} + {formatIsoRelative(notification.createdAt)}

diff --git a/apps/portal/src/features/orders/hooks/useOrderUpdates.ts b/apps/portal/src/features/orders/hooks/useOrderUpdates.ts index f1720585..cc5bd831 100644 --- a/apps/portal/src/features/orders/hooks/useOrderUpdates.ts +++ b/apps/portal/src/features/orders/hooks/useOrderUpdates.ts @@ -25,10 +25,20 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda let isCancelled = false; let eventSource: EventSource | null = null; let reconnectTimeout: ReturnType | null = null; + let reconnectAttempt = 0; const baseUrl = resolveBaseUrl(); const url = new URL(`/api/orders/${orderId}/events`, baseUrl).toString(); + const getReconnectDelayMs = () => { + // Exponential backoff with jitter (cap at 30s) to avoid thundering herd after outages/deploys. + const baseMs = 1000; + const maxMs = 30000; + const exponential = Math.min(baseMs * Math.pow(2, reconnectAttempt), maxMs); + const jitter = Math.floor(Math.random() * 500); // 0-500ms + return Math.min(exponential + jitter, maxMs); + }; + const connect = () => { if (isCancelled) return; @@ -36,6 +46,11 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda const es = new EventSource(url, { withCredentials: true }); eventSource = es; + es.onopen = () => { + reconnectAttempt = 0; + logger.debug("Order updates stream connected", { orderId }); + }; + const handleMessage = (event: MessageEvent) => { try { const parsed = JSON.parse(event.data) as OrderStreamEvent; @@ -58,7 +73,9 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda eventSource = null; if (!isCancelled) { - reconnectTimeout = setTimeout(connect, 5000); + reconnectAttempt += 1; + const delay = getReconnectDelayMs(); + reconnectTimeout = setTimeout(connect, delay); } }; diff --git a/apps/portal/src/features/orders/utils/order-display.ts b/apps/portal/src/features/orders/utils/order-display.ts index d1e33686..5396e14c 100644 --- a/apps/portal/src/features/orders/utils/order-display.ts +++ b/apps/portal/src/features/orders/utils/order-display.ts @@ -4,6 +4,7 @@ */ import type { OrderDisplayItem } from "@customer-portal/domain/orders"; +import { summarizeItems } from "./summary"; export { buildOrderDisplayItems, categorizeOrderItem } from "@customer-portal/domain/orders"; @@ -18,14 +19,5 @@ export type { * Summarize order display items for compact display */ export function summarizeOrderDisplayItems(items: OrderDisplayItem[], fallback: string): string { - if (items.length === 0) { - return fallback; - } - - const [primary, ...rest] = items; - if (rest.length === 0) { - return primary.name; - } - - return `${primary.name} +${rest.length} more`; + return summarizeItems(items, fallback); } diff --git a/apps/portal/src/features/orders/utils/order-presenters.ts b/apps/portal/src/features/orders/utils/order-presenters.ts index 5f183ffb..b0bfd577 100644 --- a/apps/portal/src/features/orders/utils/order-presenters.ts +++ b/apps/portal/src/features/orders/utils/order-presenters.ts @@ -1,3 +1,5 @@ +import { summarizeItems } from "./summary"; + export { normalizeBillingCycle, deriveOrderStatusDescriptor, @@ -18,15 +20,5 @@ export function summarizePrimaryItem( items: Array<{ name?: string; quantity?: number }> | undefined, fallback: string ): string { - if (!items || items.length === 0) { - return fallback; - } - - const [primary, ...rest] = items; - let summary = primary?.name || fallback; - const additionalCount = rest.filter(Boolean).length; - if (additionalCount > 0) { - summary += ` +${additionalCount} more`; - } - return summary; + return summarizeItems(items, fallback); } diff --git a/apps/portal/src/features/orders/utils/summary.ts b/apps/portal/src/features/orders/utils/summary.ts new file mode 100644 index 00000000..4a3b1776 --- /dev/null +++ b/apps/portal/src/features/orders/utils/summary.ts @@ -0,0 +1,17 @@ +export function summarizeItems( + items: T[] | undefined, + fallback: string +): string { + if (!items || items.length === 0) { + return fallback; + } + + const [primary, ...rest] = items; + const summary = primary?.name || fallback; + const additionalCount = rest.filter(Boolean).length; + if (additionalCount > 0) { + return `${summary} +${additionalCount} more`; + } + + return summary; +} diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index afc39a2d..54092c4c 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -33,7 +33,9 @@ import { type OrderDisplayItemCharge, } from "@/features/orders/utils/order-display"; import type { OrderDetails, OrderUpdateEventPayload } from "@customer-portal/domain/orders"; +import { Formatting } from "@customer-portal/domain/toolkit"; import { cn } from "@/lib/utils/cn"; +import { formatIsoDate } from "@/lib/utils"; const STATUS_PILL_VARIANT: Record< "success" | "info" | "warning" | "neutral", @@ -136,12 +138,6 @@ const describeCharge = (charge: OrderDisplayItemCharge): string => { return charge.label.toLowerCase(); }; -const yenFormatter = new Intl.NumberFormat("ja-JP", { - style: "currency", - currency: "JPY", - maximumFractionDigits: 0, -}); - export function OrderDetailContainer() { const params = useParams<{ id: string }>(); const searchParams = useSearchParams(); @@ -184,14 +180,8 @@ export function OrderDetailContainer() { const placedDate = useMemo(() => { if (!data?.createdDate) return null; - const date = new Date(data.createdDate); - if (Number.isNaN(date.getTime())) return null; - return date.toLocaleDateString("en-US", { - weekday: "long", - month: "long", - day: "numeric", - year: "numeric", - }); + const formatted = formatIsoDate(data.createdDate, { dateStyle: "full" }); + return formatted === "Invalid date" ? null : formatted; }, [data?.createdDate]); const serviceLabel = useMemo(() => { @@ -335,7 +325,7 @@ export function OrderDetailContainer() { Monthly

- {yenFormatter.format(totals.monthlyTotal)} + {Formatting.formatCurrency(totals.monthlyTotal)}

)} @@ -345,7 +335,7 @@ export function OrderDetailContainer() { One-Time

- {yenFormatter.format(totals.oneTimeTotal)} + {Formatting.formatCurrency(totals.oneTimeTotal)}

)} @@ -415,7 +405,7 @@ export function OrderDetailContainer() { className="whitespace-nowrap text-lg" > - {yenFormatter.format(charge.amount)} + {Formatting.formatCurrency(charge.amount)} {descriptor} diff --git a/apps/portal/src/features/services/components/sim/SimConfigureView.tsx b/apps/portal/src/features/services/components/sim/SimConfigureView.tsx index 6753acdd..cfe637bc 100644 --- a/apps/portal/src/features/services/components/sim/SimConfigureView.tsx +++ b/apps/portal/src/features/services/components/sim/SimConfigureView.tsx @@ -20,6 +20,7 @@ import { } from "@heroicons/react/24/outline"; import type { UseSimConfigureResult } from "@/features/services/hooks/useSimConfigure"; import type { SimActivationFeeCatalogItem } from "@customer-portal/domain/services"; +import { formatIsoMonthDay } from "@/lib/utils"; type Props = UseSimConfigureResult & { onConfirm: () => void; @@ -456,7 +457,7 @@ export function SimConfigureView({ Activation: {activationType === "Scheduled" && scheduledActivationDate - ? `${new Date(scheduledActivationDate).toLocaleDateString("en-US", { month: "short", day: "numeric" })}` + ? `${formatIsoMonthDay(scheduledActivationDate)}` : activationType || "Not selected"} diff --git a/apps/portal/src/features/services/views/InternetPlans.tsx b/apps/portal/src/features/services/views/InternetPlans.tsx index 1a729b31..93dcc51f 100644 --- a/apps/portal/src/features/services/views/InternetPlans.tsx +++ b/apps/portal/src/features/services/views/InternetPlans.tsx @@ -27,7 +27,7 @@ import { useRequestInternetEligibilityCheck, } from "@/features/services/hooks"; import { useAuthSession } from "@/features/auth/services/auth.store"; -import { cn } from "@/lib/utils"; +import { cn, formatIsoDate } from "@/lib/utils"; type AutoRequestStatus = "idle" | "submitting" | "submitted" | "failed" | "missing_address"; @@ -705,7 +705,7 @@ export function InternetPlansContainer() { {requestedAt && (

- Request submitted: {new Date(requestedAt).toLocaleDateString()} + Request submitted: {formatIsoDate(requestedAt)}

)} diff --git a/apps/portal/src/features/sim/components/DataUsageChart.tsx b/apps/portal/src/features/sim/components/DataUsageChart.tsx index 87dcb219..05387fb8 100644 --- a/apps/portal/src/features/sim/components/DataUsageChart.tsx +++ b/apps/portal/src/features/sim/components/DataUsageChart.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import { formatIsoMonthDay } from "@/lib/utils"; import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; export interface SimUsage { @@ -197,10 +198,7 @@ export function DataUsageChart({ return (
- {new Date(day.date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })} + {formatIsoMonthDay(day.date)}
diff --git a/apps/portal/src/features/sim/components/SimDetailsCard.tsx b/apps/portal/src/features/sim/components/SimDetailsCard.tsx index 2c9701ca..e8a776c5 100644 --- a/apps/portal/src/features/sim/components/SimDetailsCard.tsx +++ b/apps/portal/src/features/sim/components/SimDetailsCard.tsx @@ -11,6 +11,7 @@ import { XCircleIcon, } from "@heroicons/react/24/outline"; import type { SimDetails } from "@customer-portal/domain/sim"; +import { formatIsoDate } from "@/lib/utils"; // Re-export for backwards compatibility export type { SimDetails }; @@ -81,16 +82,8 @@ export function SimDetailsCard({ }; const formatDate = (dateString: string) => { - try { - const date = new Date(dateString); - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); - } catch { - return dateString; - } + const formatted = formatIsoDate(dateString, { fallback: dateString, dateStyle: "medium" }); + return formatted === "Invalid date" ? dateString : formatted; }; const formatQuota = (quotaMb: number) => { diff --git a/apps/portal/src/features/sim/components/SimManagementSection.tsx b/apps/portal/src/features/sim/components/SimManagementSection.tsx index ebb0b65f..56ac2fdb 100644 --- a/apps/portal/src/features/sim/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim/components/SimManagementSection.tsx @@ -13,9 +13,9 @@ import { import { apiClient } from "@/lib/api"; import { useSubscription, useSubscriptionInvoices } from "@/features/subscriptions/hooks"; import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling"; -import { format } from "date-fns"; import { Formatting } from "@customer-portal/domain/toolkit"; import { Button } from "@/components/atoms/button"; +import { formatIsoDate } from "@/lib/utils"; const { formatCurrency } = Formatting; @@ -138,14 +138,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro } }; - const formatDate = (dateString: string | undefined) => { - if (!dateString) return "N/A"; - try { - return format(new Date(dateString), "MMM d yyyy"); - } catch { - return "Invalid date"; - } - }; + const formatDate = (dateString: string | undefined) => formatIsoDate(dateString); if (loading) { return ( diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx index d5af14d8..ee77b043 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx @@ -1,7 +1,6 @@ "use client"; import { forwardRef } from "react"; -import { format } from "date-fns"; import { CalendarIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; import { StatusPill } from "@/components/atoms/status-pill"; import { Button } from "@/components/atoms/button"; @@ -9,7 +8,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard"; import type { Subscription } from "@customer-portal/domain/subscriptions"; import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; -import { cn } from "@/lib/utils"; +import { cn, formatIsoDate } from "@/lib/utils"; import { getBillingCycleLabel, getSubscriptionStatusIcon, @@ -25,12 +24,7 @@ interface SubscriptionCardProps { } const formatDate = (dateString: string | undefined) => { - if (!dateString) return "N/A"; - try { - return format(new Date(dateString), "MMM d, yyyy"); - } catch { - return "Invalid date"; - } + return formatIsoDate(dateString); }; export const SubscriptionCard = forwardRef( diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx index a83a4d7c..a4b415b9 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx @@ -1,7 +1,6 @@ "use client"; import { forwardRef } from "react"; -import { format } from "date-fns"; import { ServerIcon, CalendarIcon, @@ -14,7 +13,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard"; import type { Subscription } from "@customer-portal/domain/subscriptions"; import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; -import { cn } from "@/lib/utils"; +import { cn, formatIsoDate } from "@/lib/utils"; import { getBillingCycleLabel, getSubscriptionStatusIcon, @@ -28,12 +27,7 @@ interface SubscriptionDetailsProps { } const formatDate = (dateString: string | undefined) => { - if (!dateString) return "N/A"; - try { - return format(new Date(dateString), "MMM d, yyyy"); - } catch { - return "Invalid date"; - } + return formatIsoDate(dateString); }; const isSimService = (productName: string) => { diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx index 059f6ff9..0d65bd6d 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx @@ -2,7 +2,6 @@ import { useCallback, useMemo } from "react"; import { useRouter } from "next/navigation"; -import { format } from "date-fns"; import { ServerIcon, CheckCircleIcon, @@ -18,7 +17,7 @@ import { type Subscription, } from "@customer-portal/domain/subscriptions"; import { Formatting } from "@customer-portal/domain/toolkit"; -import { cn } from "@/lib/utils"; +import { cn, formatIsoDate } from "@/lib/utils"; const { formatCurrency } = Formatting; @@ -80,12 +79,7 @@ const getBillingPeriodText = (cycle: string): string => { }; const formatDate = (dateString: string | undefined) => { - if (!dateString) return "N/A"; - try { - return format(new Date(dateString), "MMM d, yyyy"); - } catch { - return "N/A"; - } + return formatIsoDate(dateString); }; export function SubscriptionTable({ diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx index 0176ab52..d22e546c 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx @@ -10,12 +10,12 @@ import { GlobeAltIcon, XCircleIcon, } from "@heroicons/react/24/outline"; -import { format } from "date-fns"; import { useSubscription } from "@/features/subscriptions/hooks"; import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList"; import { Formatting } from "@customer-portal/domain/toolkit"; import { PageLayout } from "@/components/templates/PageLayout"; import { StatusPill } from "@/components/atoms/status-pill"; +import { formatIsoDate } from "@/lib/utils"; const { formatCurrency: sharedFormatCurrency } = Formatting; import { SimManagementSection } from "@/features/sim"; @@ -48,14 +48,7 @@ export function SubscriptionDetailContainer() { return; }, [searchParams]); - const formatDate = (dateString: string | undefined) => { - if (!dateString) return "N/A"; - try { - return format(new Date(dateString), "MMM d, yyyy"); - } catch { - return "Invalid date"; - } - }; + const formatDate = (dateString: string | undefined) => formatIsoDate(dateString); const formatCurrency = (amount: number) => sharedFormatCurrency(amount || 0); diff --git a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx index ffad545d..c8a897ee 100644 --- a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx +++ b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx @@ -2,7 +2,6 @@ import { CalendarIcon, ClockIcon, TagIcon, ArrowLeftIcon } from "@heroicons/react/24/outline"; import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid"; -import { format, formatDistanceToNow } from "date-fns"; import { PageLayout } from "@/components/templates/PageLayout"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { Button } from "@/components/atoms"; @@ -12,6 +11,7 @@ import { getCaseStatusClasses, getCasePriorityClasses, } from "@/features/support/utils"; +import { formatIsoDate, formatIsoRelative } from "@/lib/utils"; interface SupportCaseDetailViewProps { caseId: string; @@ -104,14 +104,11 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
- Created {format(new Date(supportCase.createdAt), "MMM d, yyyy")} + Created {formatIsoDate(supportCase.createdAt)}
- - Updated{" "} - {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })} - + Updated {formatIsoRelative(supportCase.updatedAt)}
{supportCase.category && (
@@ -121,7 +118,7 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) { )} {supportCase.closedAt && (
- ✓ Closed {format(new Date(supportCase.closedAt), "MMM d, yyyy")} + ✓ Closed {formatIsoDate(supportCase.closedAt)}
)}
diff --git a/apps/portal/src/features/support/views/SupportCasesView.tsx b/apps/portal/src/features/support/views/SupportCasesView.tsx index 9d37ba62..3f26643d 100644 --- a/apps/portal/src/features/support/views/SupportCasesView.tsx +++ b/apps/portal/src/features/support/views/SupportCasesView.tsx @@ -13,7 +13,6 @@ import { XMarkIcon, } from "@heroicons/react/24/outline"; import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid"; -import { formatDistanceToNow } from "date-fns"; import { PageLayout } from "@/components/templates/PageLayout"; import { AnimatedCard } from "@/components/molecules"; import { Button } from "@/components/atoms"; @@ -29,6 +28,7 @@ import { getCaseStatusClasses, getCasePriorityClasses, } from "@/features/support/utils"; +import { formatIsoRelative } from "@/lib/utils"; export function SupportCasesView() { const router = useRouter(); @@ -217,8 +217,7 @@ export function SupportCasesView() { {/* Timestamp */}

- Updated{" "} - {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })} + Updated {formatIsoRelative(supportCase.updatedAt)}

diff --git a/apps/portal/src/features/support/views/SupportHomeView.tsx b/apps/portal/src/features/support/views/SupportHomeView.tsx index 39389817..7eeaf264 100644 --- a/apps/portal/src/features/support/views/SupportHomeView.tsx +++ b/apps/portal/src/features/support/views/SupportHomeView.tsx @@ -11,13 +11,13 @@ import { CheckCircleIcon, } from "@heroicons/react/24/outline"; import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid"; -import { formatDistanceToNow } from "date-fns"; import { PageLayout } from "@/components/templates/PageLayout"; import { AnimatedCard } from "@/components/molecules"; import { Button } from "@/components/atoms"; import { EmptyState } from "@/components/atoms/empty-state"; import { useSupportCases } from "@/features/support/hooks/useSupportCases"; import { getCaseStatusIcon, getCaseStatusClasses } from "@/features/support/utils"; +import { formatIsoRelative } from "@/lib/utils"; export function SupportHomeView() { const router = useRouter(); @@ -125,7 +125,7 @@ export function SupportHomeView() {

{supportCase.subject}

- {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })} + {formatIsoRelative(supportCase.updatedAt)}
diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index 85d7b2ee..8a6f5ccd 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -7,14 +7,16 @@ export type { PathParams, } from "./runtime/client"; export { ApiError, isApiError } from "./runtime/client"; +export { onUnauthorized } from "./unauthorized"; // Re-export API helpers export * from "./response-helpers"; // Import createClient for internal use import { createClient, ApiError } from "./runtime/client"; -import { parseDomainError } from "./response-helpers"; +import { getApiErrorMessage } from "./runtime/error-message"; import { logger } from "@/lib/logger"; +import { emitUnauthorized } from "./unauthorized"; /** * Auth endpoints that should NOT trigger automatic logout on 401 @@ -41,38 +43,6 @@ function isAuthEndpoint(url: string): boolean { } } -/** - * Extract error message from API error body - * Handles both `{ message }` and `{ error: { message } }` formats - */ -function extractErrorMessage(body: unknown): string | null { - const domainError = parseDomainError(body); - if (domainError) { - return domainError.error.message; - } - - if (!body || typeof body !== "object") { - return null; - } - - // Check for nested error.message format (standard API error response) - const bodyWithError = body as { error?: { message?: unknown } }; - if (bodyWithError.error && typeof bodyWithError.error === "object") { - const errorMessage = bodyWithError.error.message; - if (typeof errorMessage === "string") { - return errorMessage; - } - } - - // Check for top-level message - const bodyWithMessage = body as { message?: unknown }; - if (typeof bodyWithMessage.message === "string") { - return bodyWithMessage.message; - } - - return null; -} - /** * Global error handler for API client * Handles authentication errors and triggers logout when needed @@ -87,14 +57,7 @@ async function handleApiError(response: Response): Promise { if (response.status === 401 && !isAuthEndpoint(response.url)) { logger.warn("Received 401 Unauthorized response - triggering logout"); - // Dispatch a custom event that the auth system will listen to - if (typeof window !== "undefined") { - window.dispatchEvent( - new CustomEvent("auth:unauthorized", { - detail: { url: response.url, status: response.status }, - }) - ); - } + emitUnauthorized({ url: response.url, status: response.status }); } // Still throw the error so the calling code can handle it @@ -106,7 +69,7 @@ async function handleApiError(response: Response): Promise { const contentType = cloned.headers.get("content-type"); if (contentType?.includes("application/json")) { body = await cloned.json(); - const extractedMessage = extractErrorMessage(body); + const extractedMessage = getApiErrorMessage(body); if (extractedMessage) { message = extractedMessage; } diff --git a/apps/portal/src/lib/api/runtime/client.ts b/apps/portal/src/lib/api/runtime/client.ts index 3523d620..e01cc8fe 100644 --- a/apps/portal/src/lib/api/runtime/client.ts +++ b/apps/portal/src/lib/api/runtime/client.ts @@ -1,5 +1,6 @@ -import { parseDomainError, type ApiResponse } from "../response-helpers"; +import type { ApiResponse } from "../response-helpers"; import { logger } from "@/lib/logger"; +import { getApiErrorMessage } from "./error-message"; export class ApiError extends Error { constructor( @@ -131,20 +132,7 @@ const getBodyMessage = (body: unknown): string | null => { if (typeof body === "string") { return body; } - - const domainError = parseDomainError(body); - if (domainError) { - return domainError.error.message; - } - - if (body && typeof body === "object" && "message" in body) { - const maybeMessage = (body as { message?: unknown }).message; - if (typeof maybeMessage === "string") { - return maybeMessage; - } - } - - return null; + return getApiErrorMessage(body); }; async function defaultHandleError(response: Response) { diff --git a/apps/portal/src/lib/api/runtime/error-message.ts b/apps/portal/src/lib/api/runtime/error-message.ts new file mode 100644 index 00000000..1e9cf3aa --- /dev/null +++ b/apps/portal/src/lib/api/runtime/error-message.ts @@ -0,0 +1,35 @@ +import { parseDomainError } from "../response-helpers"; + +/** + * Extract a user-facing error message from an API error payload. + * + * Supports: + * - domain envelope: `{ success: false, error: { code, message, details? } }` + * - common nested error: `{ error: { message } }` + * - top-level message: `{ message }` + */ +export function getApiErrorMessage(payload: unknown): string | null { + const domainError = parseDomainError(payload); + if (domainError) { + return domainError.error.message; + } + + if (!payload || typeof payload !== "object") { + return null; + } + + const bodyWithError = payload as { error?: { message?: unknown } }; + if (bodyWithError.error && typeof bodyWithError.error === "object") { + const errorMessage = bodyWithError.error.message; + if (typeof errorMessage === "string") { + return errorMessage; + } + } + + const bodyWithMessage = payload as { message?: unknown }; + if (typeof bodyWithMessage.message === "string") { + return bodyWithMessage.message; + } + + return null; +} diff --git a/apps/portal/src/lib/api/unauthorized.ts b/apps/portal/src/lib/api/unauthorized.ts new file mode 100644 index 00000000..208795d1 --- /dev/null +++ b/apps/portal/src/lib/api/unauthorized.ts @@ -0,0 +1,33 @@ +export type UnauthorizedDetail = { + url?: string; + status?: number; +}; + +export type UnauthorizedListener = (detail: UnauthorizedDetail) => void; + +const listeners = new Set(); + +/** + * Subscribe to "unauthorized" events emitted by the API layer. + * + * Returns an unsubscribe function. + */ +export function onUnauthorized(listener: UnauthorizedListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** + * Emit an "unauthorized" event to all listeners. + * + * Intended to be called by the API client when a non-auth endpoint returns 401. + */ +export function emitUnauthorized(detail: UnauthorizedDetail): void { + for (const listener of listeners) { + try { + listener(detail); + } catch { + // Never let one listener break other listeners or the caller. + } + } +} diff --git a/apps/portal/src/lib/utils/date.ts b/apps/portal/src/lib/utils/date.ts new file mode 100644 index 00000000..7862e0d1 --- /dev/null +++ b/apps/portal/src/lib/utils/date.ts @@ -0,0 +1,72 @@ +import { Formatting } from "@customer-portal/domain/toolkit"; + +export type FormatDateFallbackOptions = { + fallback?: string; + locale?: string; + dateStyle?: "short" | "medium" | "long" | "full"; + timeStyle?: "short" | "medium" | "long" | "full"; + includeTime?: boolean; + timezone?: string; +}; + +export function formatIsoDate( + iso: string | null | undefined, + options: FormatDateFallbackOptions = {} +): string { + const { + fallback = "N/A", + locale, + dateStyle = "medium", + timeStyle = "short", + includeTime = false, + timezone, + } = options; + + if (!iso) return fallback; + if (!Formatting.isValidDate(iso)) return "Invalid date"; + + return Formatting.formatDate(iso, { locale, dateStyle, timeStyle, includeTime, timezone }); +} + +export function formatIsoRelative( + iso: string | null | undefined, + options: { fallback?: string; locale?: string } = {} +): string { + const { fallback = "N/A", locale } = options; + if (!iso) return fallback; + if (!Formatting.isValidDate(iso)) return "Invalid date"; + return Formatting.formatRelativeDate(iso, { locale }); +} + +export function formatIsoMonthDay( + iso: string | null | undefined, + options: { fallback?: string; locale?: string } = {} +): string { + const { fallback = "N/A", locale = "en-US" } = options; + if (!iso) return fallback; + if (!Formatting.isValidDate(iso)) return "Invalid date"; + try { + const date = new Date(iso); + return date.toLocaleDateString(locale, { month: "short", day: "numeric" }); + } catch { + return "Invalid date"; + } +} + +export function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +export function isToday(date: Date, now: Date = new Date()): boolean { + return isSameDay(date, now); +} + +export function isYesterday(date: Date, now: Date = new Date()): boolean { + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + return isSameDay(date, yesterday); +} diff --git a/apps/portal/src/lib/utils/index.ts b/apps/portal/src/lib/utils/index.ts index 02d16a5d..e4c8f793 100644 --- a/apps/portal/src/lib/utils/index.ts +++ b/apps/portal/src/lib/utils/index.ts @@ -1,4 +1,13 @@ export { cn } from "./cn"; +export { + formatIsoDate, + formatIsoRelative, + formatIsoMonthDay, + isSameDay, + isToday, + isYesterday, + type FormatDateFallbackOptions, +} from "./date"; export { parseError, getErrorMessage,