From f447ba1800d0c207a088b5f7a2f0d6bf59a1c7c6 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 13 Jan 2026 14:25:14 +0900 Subject: [PATCH] Refactor codebase: eliminate duplication, standardize patterns, resolve circular deps Phase 1: Portal Duplication Cleanup - Delete apps/portal/src/lib/ directory (12 duplicate files) - Update imports to use canonical locations (core/, shared/) Phase 2: Domain Package Standardization - Add contract.ts to notifications and checkout modules - Update billing schema to derive enums from contract Phase 3: BFF Error Handling - Remove hardcoded test SIM number from SimValidationService - Use ConfigService for TEST_SIM_ACCOUNT env variable Phase 4: Circular Dependency Resolution - Create VoiceOptionsModule to break FreebitModule <-> SimManagementModule cycle - Remove forwardRef usage between these modules - Move SimVoiceOptionsService to new voice-options module Co-Authored-By: Claude Opus 4.5 --- .../integrations/freebit/freebit.module.ts | 6 +- .../services/freebit-mapper.service.ts | 8 +- .../freebit/services/freebit-voice.service.ts | 8 +- .../services/sim-validation.service.ts | 42 +- .../sim-management/sim-management.module.ts | 17 +- apps/bff/src/modules/voice-options/index.ts | 5 + .../services/voice-options.service.ts} | 2 +- .../voice-options/voice-options.module.ts | 27 ++ apps/portal/src/app/layout.tsx | 2 +- .../auth/components/SessionTimeoutWarning.tsx | 2 +- apps/portal/src/lib/api/index.ts | 142 ------ apps/portal/src/lib/api/response-helpers.ts | 70 --- apps/portal/src/lib/api/runtime/client.ts | 408 ------------------ .../src/lib/api/runtime/error-message.ts | 35 -- apps/portal/src/lib/api/unauthorized.ts | 33 -- apps/portal/src/lib/constants/countries.ts | 47 -- .../src/lib/constants/japan-prefectures.ts | 88 ---- apps/portal/src/lib/hooks/index.ts | 4 - apps/portal/src/lib/hooks/useCurrency.ts | 26 -- apps/portal/src/lib/hooks/useDebounce.ts | 22 - .../portal/src/lib/hooks/useFormatCurrency.ts | 60 --- apps/portal/src/lib/hooks/useLocalStorage.ts | 80 ---- apps/portal/src/lib/hooks/useMediaQuery.ts | 35 -- apps/portal/src/lib/hooks/useZodForm.ts | 271 ------------ apps/portal/src/lib/logger.ts | 85 ---- apps/portal/src/lib/providers.tsx | 70 --- .../src/lib/services/currency.service.ts | 16 - apps/portal/src/lib/utils/cn.ts | 10 - apps/portal/src/lib/utils/date.ts | 72 ---- apps/portal/src/lib/utils/error-handling.ts | 174 -------- apps/portal/src/lib/utils/index.ts | 21 - packages/domain/billing/schema.ts | 15 +- packages/domain/checkout/contract.ts | 43 ++ packages/domain/checkout/index.ts | 19 +- packages/domain/checkout/schema.ts | 29 +- packages/domain/notifications/contract.ts | 173 ++++++++ packages/domain/notifications/index.ts | 15 +- packages/domain/notifications/schema.ts | 159 +------ 38 files changed, 333 insertions(+), 2008 deletions(-) create mode 100644 apps/bff/src/modules/voice-options/index.ts rename apps/bff/src/modules/{subscriptions/sim-management/services/sim-voice-options.service.ts => voice-options/services/voice-options.service.ts} (99%) create mode 100644 apps/bff/src/modules/voice-options/voice-options.module.ts delete mode 100644 apps/portal/src/lib/api/index.ts delete mode 100644 apps/portal/src/lib/api/response-helpers.ts delete mode 100644 apps/portal/src/lib/api/runtime/client.ts delete mode 100644 apps/portal/src/lib/api/runtime/error-message.ts delete mode 100644 apps/portal/src/lib/api/unauthorized.ts delete mode 100644 apps/portal/src/lib/constants/countries.ts delete mode 100644 apps/portal/src/lib/constants/japan-prefectures.ts delete mode 100644 apps/portal/src/lib/hooks/index.ts delete mode 100644 apps/portal/src/lib/hooks/useCurrency.ts delete mode 100644 apps/portal/src/lib/hooks/useDebounce.ts delete mode 100644 apps/portal/src/lib/hooks/useFormatCurrency.ts delete mode 100644 apps/portal/src/lib/hooks/useLocalStorage.ts delete mode 100644 apps/portal/src/lib/hooks/useMediaQuery.ts delete mode 100644 apps/portal/src/lib/hooks/useZodForm.ts delete mode 100644 apps/portal/src/lib/logger.ts delete mode 100644 apps/portal/src/lib/providers.tsx delete mode 100644 apps/portal/src/lib/services/currency.service.ts delete mode 100644 apps/portal/src/lib/utils/cn.ts delete mode 100644 apps/portal/src/lib/utils/date.ts delete mode 100644 apps/portal/src/lib/utils/error-handling.ts delete mode 100644 apps/portal/src/lib/utils/index.ts create mode 100644 packages/domain/checkout/contract.ts create mode 100644 packages/domain/notifications/contract.ts diff --git a/apps/bff/src/integrations/freebit/freebit.module.ts b/apps/bff/src/integrations/freebit/freebit.module.ts index 5592ae08..9d0f6488 100644 --- a/apps/bff/src/integrations/freebit/freebit.module.ts +++ b/apps/bff/src/integrations/freebit/freebit.module.ts @@ -1,4 +1,4 @@ -import { Module, forwardRef } from "@nestjs/common"; +import { Module } from "@nestjs/common"; import { FreebitOrchestratorService } from "./services/freebit-orchestrator.service.js"; import { FreebitMapperService } from "./services/freebit-mapper.service.js"; import { FreebitOperationsService } from "./services/freebit-operations.service.js"; @@ -11,10 +11,10 @@ import { FreebitPlanService } from "./services/freebit-plan.service.js"; import { FreebitVoiceService } from "./services/freebit-voice.service.js"; import { FreebitCancellationService } from "./services/freebit-cancellation.service.js"; import { FreebitEsimService } from "./services/freebit-esim.service.js"; -import { SimManagementModule } from "../../modules/subscriptions/sim-management/sim-management.module.js"; +import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.module.js"; @Module({ - imports: [forwardRef(() => SimManagementModule)], + imports: [VoiceOptionsModule], providers: [ // Core services FreebitClientService, diff --git a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts index 90400b1e..059630b7 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, Optional } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import type { FreebitAccountDetailsResponse, @@ -8,13 +8,15 @@ import type { SimUsage, SimTopUpHistory, } from "../interfaces/freebit.types.js"; -import type { SimVoiceOptionsService } from "@bff/modules/subscriptions/sim-management/services/sim-voice-options.service.js"; +import type { VoiceOptionsService } from "@bff/modules/voice-options/services/voice-options.service.js"; @Injectable() export class FreebitMapperService { constructor( @Inject(Logger) private readonly logger: Logger, - @Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: SimVoiceOptionsService + @Optional() + @Inject("SimVoiceOptionsService") + private readonly voiceOptionsService?: VoiceOptionsService ) {} private parseOptionFlag(value: unknown, defaultValue: boolean = false): boolean { diff --git a/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts b/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts index 3b4f8b56..ba52a1b1 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts @@ -1,10 +1,10 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Injectable, Inject, BadRequestException, Optional } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { FreebitClientService } from "./freebit-client.service.js"; import { FreebitRateLimiterService } from "./freebit-rate-limiter.service.js"; import { FreebitAccountService } from "./freebit-account.service.js"; -import type { SimVoiceOptionsService } from "@bff/modules/subscriptions/sim-management/services/sim-voice-options.service.js"; +import type { VoiceOptionsService } from "@bff/modules/voice-options/services/voice-options.service.js"; import type { FreebitVoiceOptionSettings, FreebitVoiceOptionRequest, @@ -30,7 +30,9 @@ export class FreebitVoiceService { private readonly rateLimiter: FreebitRateLimiterService, private readonly accountService: FreebitAccountService, @Inject(Logger) private readonly logger: Logger, - @Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: SimVoiceOptionsService + @Optional() + @Inject("SimVoiceOptionsService") + private readonly voiceOptionsService?: VoiceOptionsService ) {} /** diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts index a464c784..368d9bc2 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts @@ -1,4 +1,5 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { SubscriptionsService } from "../../subscriptions.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -13,6 +14,7 @@ import { export class SimValidationService { constructor( private readonly subscriptionsService: SubscriptionsService, + private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger ) {} @@ -40,22 +42,28 @@ export class SimValidationService { // Extract SIM account identifier (using domain function) let account = extractSimAccountFromSubscription(subscription); - // Final fallback - for testing, use the known test SIM number + // If no account found, check for test fallback from env or throw error if (!account) { - // Use the specific test SIM number that should exist in the test environment - account = "02000331144508"; + const testSimAccount = this.configService.get("TEST_SIM_ACCOUNT"); - this.logger.warn( - `No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`, - { - userId, - subscriptionId, - productName: subscription.productName, - domain: subscription.domain, - customFields: subscription.customFields ? Object.keys(subscription.customFields) : [], - note: "Using known test SIM number 02000331144508 - should exist in Freebit test environment", - } - ); + if (testSimAccount) { + account = testSimAccount; + this.logger.warn( + `No SIM account identifier found for subscription ${subscriptionId}, using TEST_SIM_ACCOUNT fallback`, + { + userId, + subscriptionId, + productName: subscription.productName, + domain: subscription.domain, + customFields: subscription.customFields ? Object.keys(subscription.customFields) : [], + } + ); + } else { + throw new BadRequestException( + `No SIM account identifier found for subscription ${subscriptionId}. ` + + "Please ensure the subscription has a valid SIM account number in custom fields." + ); + } } // Clean up the account format (using domain function) @@ -102,9 +110,9 @@ export class SimValidationService { subscriptionId ); - // Check for specific SIM data - const expectedSimNumber = "02000331144508"; - const expectedEid = "89049032000001000000043598005455"; + // Check for specific SIM data (from config or use defaults for testing) + const expectedSimNumber = this.configService.get("TEST_SIM_ACCOUNT", ""); + const expectedEid = this.configService.get("TEST_SIM_EID", ""); const foundSimNumber = Object.entries(subscription.customFields || {}).find( ([, value]) => diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index 242f422d..890bdef6 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -1,4 +1,4 @@ -import { Module, forwardRef } from "@nestjs/common"; +import { Module } from "@nestjs/common"; import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js"; @@ -28,14 +28,14 @@ import { SimScheduleService } from "./services/sim-schedule.service.js"; import { SimActionRunnerService } from "./services/sim-action-runner.service.js"; import { SimManagementQueueService } from "./queue/sim-management.queue.js"; import { SimManagementProcessor } from "./queue/sim-management.processor.js"; -import { SimVoiceOptionsService } from "./services/sim-voice-options.service.js"; import { SimCallHistoryService } from "./services/sim-call-history.service.js"; import { ServicesModule } from "@bff/modules/services/services.module.js"; import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; +import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-options/index.js"; @Module({ imports: [ - forwardRef(() => FreebitModule), + FreebitModule, WhmcsModule, SalesforceModule, MappingsModule, @@ -44,6 +44,7 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo SftpModule, NotificationsModule, SecurityModule, + VoiceOptionsModule, ], // SimController is registered in SubscriptionsModule to ensure route order // (more specific routes like :id/sim must be registered before :id) @@ -58,7 +59,6 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo SimValidationService, SimNotificationService, SimApiNotificationService, - SimVoiceOptionsService, SimDetailsService, SimUsageService, SimTopUpService, @@ -73,10 +73,10 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo SimManagementQueueService, SimManagementProcessor, SimCallHistoryService, - // Export with token for optional injection in Freebit module + // Backwards compatibility alias: SimVoiceOptionsService -> VoiceOptionsService { provide: "SimVoiceOptionsService", - useExisting: SimVoiceOptionsService, + useExisting: VoiceOptionsService, }, ], exports: [ @@ -96,9 +96,10 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo SimScheduleService, SimActionRunnerService, SimManagementQueueService, - SimVoiceOptionsService, SimCallHistoryService, - "SimVoiceOptionsService", // Export the token + // VoiceOptionsService is exported from VoiceOptionsModule + // Backwards compatibility: re-export the token + "SimVoiceOptionsService", ], }) export class SimManagementModule {} diff --git a/apps/bff/src/modules/voice-options/index.ts b/apps/bff/src/modules/voice-options/index.ts new file mode 100644 index 00000000..4316afdf --- /dev/null +++ b/apps/bff/src/modules/voice-options/index.ts @@ -0,0 +1,5 @@ +export { VoiceOptionsModule } from "./voice-options.module.js"; +export { + VoiceOptionsService, + type VoiceOptionsSettings, +} from "./services/voice-options.service.js"; diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-voice-options.service.ts b/apps/bff/src/modules/voice-options/services/voice-options.service.ts similarity index 99% rename from apps/bff/src/modules/subscriptions/sim-management/services/sim-voice-options.service.ts rename to apps/bff/src/modules/voice-options/services/voice-options.service.ts index dcd87b0a..e1a3a598 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-voice-options.service.ts +++ b/apps/bff/src/modules/voice-options/services/voice-options.service.ts @@ -10,7 +10,7 @@ export interface VoiceOptionsSettings { } @Injectable() -export class SimVoiceOptionsService { +export class VoiceOptionsService { constructor( private readonly prisma: PrismaService, @Inject(Logger) private readonly logger: Logger diff --git a/apps/bff/src/modules/voice-options/voice-options.module.ts b/apps/bff/src/modules/voice-options/voice-options.module.ts new file mode 100644 index 00000000..67dab8bd --- /dev/null +++ b/apps/bff/src/modules/voice-options/voice-options.module.ts @@ -0,0 +1,27 @@ +import { Module } from "@nestjs/common"; +import { VoiceOptionsService } from "./services/voice-options.service.js"; + +/** + * VoiceOptionsModule provides SIM voice options storage. + * + * This module was extracted from SimManagementModule to break a circular + * dependency with FreebitModule. Both modules can now import VoiceOptionsModule + * directly without using forwardRef. + */ +@Module({ + providers: [ + VoiceOptionsService, + // Provide with token for backwards compatibility + { + provide: "VoiceOptionsService", + useExisting: VoiceOptionsService, + }, + // Legacy token for backwards compatibility with FreebitMapperService + { + provide: "SimVoiceOptionsService", + useExisting: VoiceOptionsService, + }, + ], + exports: [VoiceOptionsService, "VoiceOptionsService", "SimVoiceOptionsService"], +}) +export class VoiceOptionsModule {} diff --git a/apps/portal/src/app/layout.tsx b/apps/portal/src/app/layout.tsx index 6c63b65a..01601e36 100644 --- a/apps/portal/src/app/layout.tsx +++ b/apps/portal/src/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { headers } from "next/headers"; import "./globals.css"; -import { QueryProvider } from "@/lib/providers"; +import { QueryProvider } from "@/core/providers"; import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning"; export const metadata: Metadata = { diff --git a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx index bd925001..8c3ca023 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 "@/lib/logger"; +import { logger } from "@/core/logger"; import { useEffect, useRef, useState } from "react"; import { useAuthSession } from "@/features/auth/stores/auth.store"; diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts deleted file mode 100644 index 8a6f5ccd..00000000 --- a/apps/portal/src/lib/api/index.ts +++ /dev/null @@ -1,142 +0,0 @@ -export { createClient, resolveBaseUrl } from "./runtime/client"; -export type { - ApiClient, - AuthHeaderResolver, - CreateClientOptions, - QueryParams, - 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 { getApiErrorMessage } from "./runtime/error-message"; -import { logger } from "@/lib/logger"; -import { emitUnauthorized } from "./unauthorized"; - -/** - * Auth endpoints that should NOT trigger automatic logout on 401 - * These are endpoints where 401 means "invalid credentials", not "session expired" - */ -const AUTH_ENDPOINTS = [ - "/api/auth/login", - "/api/auth/signup", - "/api/auth/migrate", - "/api/auth/set-password", - "/api/auth/reset-password", - "/api/auth/check-password-needed", -]; - -/** - * Check if a URL path is an auth endpoint - */ -function isAuthEndpoint(url: string): boolean { - try { - const urlPath = new URL(url).pathname; - return AUTH_ENDPOINTS.some(endpoint => urlPath.endsWith(endpoint)); - } catch { - return AUTH_ENDPOINTS.some(endpoint => url.includes(endpoint)); - } -} - -/** - * Global error handler for API client - * Handles authentication errors and triggers logout when needed - */ -async function handleApiError(response: Response): Promise { - // Don't import useAuthStore at module level to avoid circular dependencies - // We'll handle auth errors by dispatching a custom event that the auth system can listen to - - // Only dispatch logout event for 401s on non-auth endpoints - // Auth endpoints (login, signup, etc.) return 401 for invalid credentials, - // which should NOT trigger logout - just show the error message - if (response.status === 401 && !isAuthEndpoint(response.url)) { - logger.warn("Received 401 Unauthorized response - triggering logout"); - - emitUnauthorized({ url: response.url, status: response.status }); - } - - // Still throw the error so the calling code can handle it - let body: unknown; - let message = response.statusText || `Request failed with status ${response.status}`; - - try { - const cloned = response.clone(); - const contentType = cloned.headers.get("content-type"); - if (contentType?.includes("application/json")) { - body = await cloned.json(); - const extractedMessage = getApiErrorMessage(body); - if (extractedMessage) { - message = extractedMessage; - } - } - } catch { - // Ignore body parse errors - } - - throw new ApiError(message, response, body); -} - -export const apiClient = createClient({ - handleError: handleApiError, -}); - -// Query keys for React Query - matching the expected structure -export const queryKeys = { - auth: { - me: () => ["auth", "me"] as const, - session: () => ["auth", "session"] as const, - }, - me: { - status: () => ["me", "status"] as const, - }, - billing: { - invoices: (params?: Record) => ["billing", "invoices", params] as const, - invoice: (id: string) => ["billing", "invoice", id] as const, - paymentMethods: () => ["billing", "payment-methods"] as const, - }, - subscriptions: { - all: () => ["subscriptions"] as const, - list: (params?: Record) => ["subscriptions", "list", params] as const, - active: () => ["subscriptions", "active"] as const, - stats: () => ["subscriptions", "stats"] as const, - detail: (id: string) => ["subscriptions", "detail", id] as const, - invoices: (id: number, params?: Record) => - ["subscriptions", "invoices", id, params] as const, - }, - dashboard: { - summary: () => ["dashboard", "summary"] as const, - }, - services: { - all: () => ["services"] as const, - products: () => ["services", "products"] as const, - internet: { - combined: () => ["services", "internet", "combined"] as const, - eligibility: () => ["services", "internet", "eligibility"] as const, - }, - sim: { - combined: () => ["services", "sim", "combined"] as const, - }, - vpn: { - combined: () => ["services", "vpn", "combined"] as const, - }, - }, - orders: { - list: () => ["orders", "list"] as const, - detail: (id: string | number) => ["orders", "detail", String(id)] as const, - }, - verification: { - residenceCard: () => ["verification", "residence-card"] as const, - }, - support: { - cases: (params?: Record) => ["support", "cases", params] as const, - case: (id: string) => ["support", "case", id] as const, - }, - currency: { - default: () => ["currency", "default"] as const, - }, -} as const; diff --git a/apps/portal/src/lib/api/response-helpers.ts b/apps/portal/src/lib/api/response-helpers.ts deleted file mode 100644 index 3ae754d4..00000000 --- a/apps/portal/src/lib/api/response-helpers.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { apiErrorResponseSchema, type ApiErrorResponse } from "@customer-portal/domain/common"; - -/** - * API Response Helper Types and Functions - * - * Generic utilities for working with API responses. - * - * Note: Success responses from the BFF return data directly (no `{ success: true, data }` envelope). - * Error responses are returned as `{ success: false, error: { code, message, details? } }` and - * are surfaced via the API client error handler. - */ - -/** - * Generic API response wrapper from the client. - * After client unwrapping, `data` contains the actual payload (not the BFF envelope). - */ -export type ApiResponse = { - data?: T; - error?: unknown; -}; - -/** - * Extract data from API response or return null. - * Useful for optional data handling. - */ -export function getNullableData(response: ApiResponse): T | null { - if (response.error || response.data === undefined) { - return null; - } - return response.data; -} - -/** - * Extract data from API response or throw error. - */ -export function getDataOrThrow(response: ApiResponse, errorMessage?: string): T { - if (response.error || response.data === undefined) { - throw new Error(errorMessage || "Failed to fetch data"); - } - return response.data; -} - -/** - * Extract data from API response or return default value. - */ -export function getDataOrDefault(response: ApiResponse, defaultValue: T): T { - return response.data ?? defaultValue; -} - -/** - * Check if response has an error. - */ -export function hasError(response: ApiResponse): boolean { - return !!response.error; -} - -/** - * Check if response has data. - */ -export function hasData(response: ApiResponse): boolean { - return response.data !== undefined && !response.error; -} - -/** - * Parse an error payload into a structured API error response. - */ -export function parseDomainError(payload: unknown): ApiErrorResponse | null { - const parsed = apiErrorResponseSchema.safeParse(payload); - return parsed.success ? parsed.data : null; -} diff --git a/apps/portal/src/lib/api/runtime/client.ts b/apps/portal/src/lib/api/runtime/client.ts deleted file mode 100644 index dd546e90..00000000 --- a/apps/portal/src/lib/api/runtime/client.ts +++ /dev/null @@ -1,408 +0,0 @@ -import type { ApiResponse } from "../response-helpers"; -import { logger } from "@/lib/logger"; -import { getApiErrorMessage } from "./error-message"; - -export class ApiError extends Error { - constructor( - message: string, - public readonly response: Response, - public readonly body?: unknown - ) { - super(message); - this.name = "ApiError"; - } -} - -export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError; - -export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; - -export type PathParams = Record; -export type QueryPrimitive = string | number | boolean; -export type QueryParams = Record< - string, - QueryPrimitive | QueryPrimitive[] | readonly QueryPrimitive[] | undefined ->; - -export interface RequestOptions { - params?: { - path?: PathParams; - query?: QueryParams; - }; - body?: unknown; - headers?: Record; - signal?: AbortSignal; - credentials?: RequestCredentials; - disableCsrf?: boolean; -} - -export type AuthHeaderResolver = () => string | undefined; - -export interface CreateClientOptions { - baseUrl?: string; - getAuthHeader?: AuthHeaderResolver; - handleError?: (response: Response) => void | Promise; - enableCsrf?: boolean; -} - -type ApiMethod = (path: string, options?: RequestOptions) => Promise>; - -export interface ApiClient { - GET: ApiMethod; - POST: ApiMethod; - PUT: ApiMethod; - PATCH: ApiMethod; - DELETE: ApiMethod; -} - -/** - * Resolve API base URL: - * - If NEXT_PUBLIC_API_BASE is set, use it (enables direct BFF calls in dev via CORS) - * - Browser fallback: Use same origin (nginx proxy in prod) - * - SSR fallback: Use localhost:4000 - */ -export const resolveBaseUrl = (explicitBase?: string): string => { - // 1. Explicit base URL provided (for testing/overrides) - if (explicitBase?.trim()) { - return explicitBase.replace(/\/+$/, ""); - } - - // 2. Check NEXT_PUBLIC_API_BASE env var (works in both browser and SSR) - // In development: set to http://localhost:4000 for direct CORS calls - // In production: typically not set, falls through to same-origin - const envBase = process.env.NEXT_PUBLIC_API_BASE; - if (envBase?.trim() && envBase.startsWith("http")) { - return envBase.replace(/\/+$/, ""); - } - - // 3. Browser fallback: use same origin (production nginx proxy) - if (typeof window !== "undefined" && window.location?.origin) { - return window.location.origin; - } - - // 4. SSR fallback for development - return "http://localhost:4000"; -}; - -const applyPathParams = (path: string, params?: PathParams): string => { - if (!params) { - return path; - } - - return path.replace(/\{([^}]+)\}/g, (_match, rawKey) => { - const key = rawKey as keyof typeof params; - - if (!(key in params)) { - throw new Error(`Missing path parameter: ${String(rawKey)}`); - } - - const value = params[key]; - return encodeURIComponent(String(value)); - }); -}; - -const buildQueryString = (query?: QueryParams): string => { - if (!query) { - return ""; - } - - const searchParams = new URLSearchParams(); - - const appendPrimitive = (key: string, value: QueryPrimitive) => { - searchParams.append(key, String(value)); - }; - - for (const [key, value] of Object.entries(query)) { - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - (value as readonly QueryPrimitive[]).forEach(entry => appendPrimitive(key, entry)); - continue; - } - - appendPrimitive(key, value as QueryPrimitive); - } - - return searchParams.toString(); -}; - -const getBodyMessage = (body: unknown): string | null => { - if (typeof body === "string") { - return body; - } - return getApiErrorMessage(body); -}; - -async function defaultHandleError(response: Response) { - if (response.ok) return; - - let body: unknown; - let message = response.statusText || `Request failed with status ${response.status}`; - - try { - const cloned = response.clone(); - const contentType = cloned.headers.get("content-type"); - if (contentType?.includes("application/json")) { - body = await cloned.json(); - const jsonMessage = getBodyMessage(body); - if (jsonMessage) { - message = jsonMessage; - } - } else { - const text = await cloned.text(); - if (text) { - body = text; - message = text; - } - } - } catch { - // Ignore body parse errors; fall back to status text - } - - throw new ApiError(message, response, body); -} - -/** - * Parse response body from the BFF. - * - * The BFF returns data directly without any wrapper envelope. - * Errors are handled via HTTP status codes (4xx/5xx) and caught by `handleError`. - */ -const parseResponseBody = async (response: Response): Promise => { - if (response.status === 204) { - return null; - } - - const contentLength = response.headers.get("content-length"); - if (contentLength === "0") { - return null; - } - - const contentType = response.headers.get("content-type") ?? ""; - - if (contentType.includes("application/json")) { - try { - return await response.json(); - } catch { - return null; - } - } - - if (contentType.includes("text/")) { - try { - return await response.text(); - } catch { - return null; - } - } - - return null; -}; - -interface CsrfTokenPayload { - success: boolean; - token: string; -} - -const isCsrfTokenPayload = (value: unknown): value is CsrfTokenPayload => { - return ( - typeof value === "object" && - value !== null && - "success" in value && - "token" in value && - typeof (value as { success: unknown }).success === "boolean" && - typeof (value as { token: unknown }).token === "string" - ); -}; - -class CsrfTokenManager { - private token: string | null = null; - private tokenPromise: Promise | null = null; - - constructor(private readonly baseUrl: string) {} - - async getToken(): Promise { - if (this.token) { - return this.token; - } - - if (this.tokenPromise) { - return this.tokenPromise; - } - - this.tokenPromise = this.fetchToken(); - try { - this.token = await this.tokenPromise; - return this.token; - } finally { - this.tokenPromise = null; - } - } - - clearToken(): void { - this.token = null; - this.tokenPromise = null; - } - - private async fetchToken(): Promise { - const url = `${this.baseUrl}/api/security/csrf/token`; - - try { - const response = await fetch(url, { - method: "GET", - credentials: "include", - headers: { - Accept: "application/json", - }, - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => response.statusText); - logger.error("CSRF token fetch failed", { - status: response.status, - statusText: response.statusText, - errorText, - url, - baseUrl: this.baseUrl, - }); - throw new Error(`Failed to fetch CSRF token: ${response.status} ${response.statusText}`); - } - - const data: unknown = await response.json(); - if (!isCsrfTokenPayload(data)) { - logger.error("Invalid CSRF token response format", { data, url }); - throw new Error("Invalid CSRF token response"); - } - - return data.token; - } catch (error) { - // Handle network errors (server not running, CORS, etc.) - if (error instanceof TypeError && error.message.includes("fetch")) { - logger.error("CSRF token fetch network error", { - error: error.message, - url, - baseUrl: this.baseUrl, - hint: "Check if BFF server is running and CORS is configured correctly", - }); - throw new Error( - `Network error fetching CSRF token from ${url}. ` + - `Please ensure the BFF server is running and accessible. ` + - `Base URL: ${this.baseUrl}` - ); - } - // Re-throw other errors - throw error; - } - } -} - -const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); - -export function createClient(options: CreateClientOptions = {}): ApiClient { - const baseUrl = resolveBaseUrl(options.baseUrl); - const resolveAuthHeader = options.getAuthHeader; - const handleError = options.handleError ?? defaultHandleError; - const enableCsrf = options.enableCsrf ?? true; - const csrfManager = enableCsrf ? new CsrfTokenManager(baseUrl) : null; - - const request = async ( - method: HttpMethod, - path: string, - opts: RequestOptions = {} - ): Promise> => { - const resolvedPath = applyPathParams(path, opts.params?.path); - const url = new URL(resolvedPath, baseUrl); - - const queryString = buildQueryString(opts.params?.query); - if (queryString) { - url.search = queryString; - } - - const headers = new Headers(opts.headers); - - const credentials = opts.credentials ?? "include"; - const init: RequestInit = { - method, - headers, - credentials, - signal: opts.signal, - }; - - const body = opts.body; - if (body !== undefined && body !== null) { - if (body instanceof FormData || body instanceof Blob) { - init.body = body as BodyInit; - } else { - if (!headers.has("Content-Type")) { - headers.set("Content-Type", "application/json"); - } - init.body = JSON.stringify(body); - } - } - - if (resolveAuthHeader && !headers.has("Authorization")) { - const headerValue = resolveAuthHeader(); - if (headerValue) { - headers.set("Authorization", headerValue); - } - } - - if ( - csrfManager && - !opts.disableCsrf && - !SAFE_METHODS.has(method) && - !headers.has("X-CSRF-Token") - ) { - try { - const csrfToken = await csrfManager.getToken(); - headers.set("X-CSRF-Token", csrfToken); - } catch (error) { - // Don't proceed without CSRF protection for mutation endpoints - logger.error("Failed to obtain CSRF token - blocking request", error); - throw new ApiError( - "CSRF protection unavailable. Please refresh the page and try again.", - new Response(null, { status: 403, statusText: "CSRF Token Required" }) - ); - } - } - - const response = await fetch(url.toString(), init); - - if (!response.ok) { - if (response.status === 403 && csrfManager) { - try { - const bodyText = await response.clone().text(); - if (bodyText.toLowerCase().includes("csrf")) { - csrfManager.clearToken(); - } - } catch { - csrfManager.clearToken(); - } - } - - await handleError(response); - // If handleError does not throw, throw a default error to ensure rejection - throw new ApiError(`Request failed with status ${response.status}`, response); - } - - const parsedBody = await parseResponseBody(response); - - if (parsedBody === undefined || parsedBody === null) { - return {}; - } - - return { - data: parsedBody as T, - }; - }; - - return { - GET: (path, opts) => request("GET", path, opts), - POST: (path, opts) => request("POST", path, opts), - PUT: (path, opts) => request("PUT", path, opts), - PATCH: (path, opts) => request("PATCH", path, opts), - DELETE: (path, opts) => request("DELETE", path, opts), - } satisfies ApiClient; -} diff --git a/apps/portal/src/lib/api/runtime/error-message.ts b/apps/portal/src/lib/api/runtime/error-message.ts deleted file mode 100644 index 1e9cf3aa..00000000 --- a/apps/portal/src/lib/api/runtime/error-message.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 208795d1..00000000 --- a/apps/portal/src/lib/api/unauthorized.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/constants/countries.ts b/apps/portal/src/lib/constants/countries.ts deleted file mode 100644 index 1b1968a5..00000000 --- a/apps/portal/src/lib/constants/countries.ts +++ /dev/null @@ -1,47 +0,0 @@ -import countries from "world-countries"; - -export interface CountryOption { - code: string; - name: string; -} - -const normalizedCountries = countries - .filter(country => country.cca2 && country.cca2.length === 2 && country.name?.common) - .map(country => { - const code = country.cca2.toUpperCase(); - const commonName = country.name.common; - return { - code, - name: commonName, - searchKeys: [commonName, country.name.official, ...(country.altSpellings ?? [])] - .filter(Boolean) - .map(entry => entry.toLowerCase()), - }; - }); - -export const COUNTRY_OPTIONS: CountryOption[] = normalizedCountries - .map(({ code, name }) => ({ code, name })) - .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); - -const COUNTRY_NAME_BY_CODE = new Map( - normalizedCountries.map(({ code, name }) => [code, name] as const) -); - -const COUNTRY_CODE_BY_NAME = new Map(); -normalizedCountries.forEach(({ code, searchKeys }) => { - searchKeys.forEach(key => { - if (key && !COUNTRY_CODE_BY_NAME.has(key)) { - COUNTRY_CODE_BY_NAME.set(key, code); - } - }); -}); - -export function getCountryName(code?: string | null): string | undefined { - if (!code) return undefined; - return COUNTRY_NAME_BY_CODE.get(code.toUpperCase()); -} - -export function getCountryCodeByName(name?: string | null): string | undefined { - if (!name) return undefined; - return COUNTRY_CODE_BY_NAME.get(name.toLowerCase()); -} diff --git a/apps/portal/src/lib/constants/japan-prefectures.ts b/apps/portal/src/lib/constants/japan-prefectures.ts deleted file mode 100644 index c7a5aa46..00000000 --- a/apps/portal/src/lib/constants/japan-prefectures.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Japanese Prefectures for address forms - * Uses English names to match WHMCS state field expectations - */ - -export interface PrefectureOption { - value: string; - label: string; -} - -/** - * All 47 Japanese prefectures - * Values are English names as expected by WHMCS - */ -export const JAPAN_PREFECTURES: PrefectureOption[] = [ - { value: "Hokkaido", label: "Hokkaido" }, - { value: "Aomori", label: "Aomori" }, - { value: "Iwate", label: "Iwate" }, - { value: "Miyagi", label: "Miyagi" }, - { value: "Akita", label: "Akita" }, - { value: "Yamagata", label: "Yamagata" }, - { value: "Fukushima", label: "Fukushima" }, - { value: "Ibaraki", label: "Ibaraki" }, - { value: "Tochigi", label: "Tochigi" }, - { value: "Gunma", label: "Gunma" }, - { value: "Saitama", label: "Saitama" }, - { value: "Chiba", label: "Chiba" }, - { value: "Tokyo", label: "Tokyo" }, - { value: "Kanagawa", label: "Kanagawa" }, - { value: "Niigata", label: "Niigata" }, - { value: "Toyama", label: "Toyama" }, - { value: "Ishikawa", label: "Ishikawa" }, - { value: "Fukui", label: "Fukui" }, - { value: "Yamanashi", label: "Yamanashi" }, - { value: "Nagano", label: "Nagano" }, - { value: "Gifu", label: "Gifu" }, - { value: "Shizuoka", label: "Shizuoka" }, - { value: "Aichi", label: "Aichi" }, - { value: "Mie", label: "Mie" }, - { value: "Shiga", label: "Shiga" }, - { value: "Kyoto", label: "Kyoto" }, - { value: "Osaka", label: "Osaka" }, - { value: "Hyogo", label: "Hyogo" }, - { value: "Nara", label: "Nara" }, - { value: "Wakayama", label: "Wakayama" }, - { value: "Tottori", label: "Tottori" }, - { value: "Shimane", label: "Shimane" }, - { value: "Okayama", label: "Okayama" }, - { value: "Hiroshima", label: "Hiroshima" }, - { value: "Yamaguchi", label: "Yamaguchi" }, - { value: "Tokushima", label: "Tokushima" }, - { value: "Kagawa", label: "Kagawa" }, - { value: "Ehime", label: "Ehime" }, - { value: "Kochi", label: "Kochi" }, - { value: "Fukuoka", label: "Fukuoka" }, - { value: "Saga", label: "Saga" }, - { value: "Nagasaki", label: "Nagasaki" }, - { value: "Kumamoto", label: "Kumamoto" }, - { value: "Oita", label: "Oita" }, - { value: "Miyazaki", label: "Miyazaki" }, - { value: "Kagoshima", label: "Kagoshima" }, - { value: "Okinawa", label: "Okinawa" }, -]; - -/** - * Format Japanese postal code with hyphen - * Input: "1234567" or "123-4567" or "1234" → Output: formatted string - */ -export function formatJapanesePostalCode(value: string): string { - const digits = value.replace(/\D/g, ""); - - if (digits.length <= 3) { - return digits; - } - - if (digits.length <= 7) { - return `${digits.slice(0, 3)}-${digits.slice(3)}`; - } - - return `${digits.slice(0, 3)}-${digits.slice(3, 7)}`; -} - -/** - * Validate Japanese postal code format (XXX-XXXX) - */ -export function isValidJapanesePostalCode(value: string): boolean { - return /^\d{3}-\d{4}$/.test(value); -} diff --git a/apps/portal/src/lib/hooks/index.ts b/apps/portal/src/lib/hooks/index.ts deleted file mode 100644 index 3ad34f8d..00000000 --- a/apps/portal/src/lib/hooks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { useLocalStorage } from "./useLocalStorage"; -export { useDebounce } from "./useDebounce"; -export { useMediaQuery, useIsMobile, useIsTablet, useIsDesktop } from "./useMediaQuery"; -export { useZodForm } from "./useZodForm"; diff --git a/apps/portal/src/lib/hooks/useCurrency.ts b/apps/portal/src/lib/hooks/useCurrency.ts deleted file mode 100644 index 00f0259f..00000000 --- a/apps/portal/src/lib/hooks/useCurrency.ts +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "@/lib/api"; -import { currencyService } from "@/lib/services/currency.service"; -import { FALLBACK_CURRENCY, type Currency } from "@customer-portal/domain/billing"; - -export function useCurrency() { - const { data, isLoading, isError, error } = useQuery({ - queryKey: queryKeys.currency.default(), - queryFn: () => currencyService.getDefaultCurrency(), - retry: 2, - }); - - const resolvedCurrency = data ?? (isError ? FALLBACK_CURRENCY : null); - const currencyCode = resolvedCurrency?.code ?? FALLBACK_CURRENCY.code; - const currencySymbol = resolvedCurrency?.prefix ?? FALLBACK_CURRENCY.prefix; - - return { - currency: resolvedCurrency, - loading: isLoading, - error: isError ? (error instanceof Error ? error.message : "Failed to load currency") : null, - currencyCode, - currencySymbol, - }; -} diff --git a/apps/portal/src/lib/hooks/useDebounce.ts b/apps/portal/src/lib/hooks/useDebounce.ts deleted file mode 100644 index 9d77b7bf..00000000 --- a/apps/portal/src/lib/hooks/useDebounce.ts +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; - -/** - * Hook that debounces a value - */ -export function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); - - return () => { - clearTimeout(handler); - }; - }, [value, delay]); - - return debouncedValue; -} diff --git a/apps/portal/src/lib/hooks/useFormatCurrency.ts b/apps/portal/src/lib/hooks/useFormatCurrency.ts deleted file mode 100644 index 359d30b1..00000000 --- a/apps/portal/src/lib/hooks/useFormatCurrency.ts +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { useCurrency } from "@/lib/hooks/useCurrency"; -import { FALLBACK_CURRENCY } from "@customer-portal/domain/billing"; -import { Formatting } from "@customer-portal/domain/toolkit"; - -export type FormatCurrencyOptions = { - currency?: string; - currencySymbol?: string; - locale?: string; - showSymbol?: boolean; -}; - -const isOptions = ( - value: string | FormatCurrencyOptions | undefined -): value is FormatCurrencyOptions => { - return typeof value === "object" && value !== null; -}; - -export function useFormatCurrency() { - const { currencyCode, currencySymbol, loading, error } = useCurrency(); - - const formatCurrency = ( - amount: number, - currencyOrOptions?: string | FormatCurrencyOptions, - options?: FormatCurrencyOptions - ) => { - const fallbackCurrency = currencyCode ?? FALLBACK_CURRENCY.code; - const fallbackSymbol = currencySymbol ?? FALLBACK_CURRENCY.prefix; - - const overrideCurrency = - (typeof currencyOrOptions === "string" && currencyOrOptions) || - (isOptions(currencyOrOptions) ? currencyOrOptions.currency : undefined) || - options?.currency; - - const overrideSymbol = - (isOptions(currencyOrOptions) ? currencyOrOptions.currencySymbol : undefined) || - options?.currencySymbol; - - const locale = - (isOptions(currencyOrOptions) ? currencyOrOptions.locale : undefined) || options?.locale; - const showSymbol = - (isOptions(currencyOrOptions) && typeof currencyOrOptions.showSymbol === "boolean" - ? currencyOrOptions.showSymbol - : undefined) ?? options?.showSymbol; - - return Formatting.formatCurrency(amount, overrideCurrency ?? fallbackCurrency, { - currencySymbol: overrideSymbol ?? fallbackSymbol, - locale, - showSymbol, - }); - }; - - return { - formatCurrency, - currencyCode, - loading, - error, - }; -} diff --git a/apps/portal/src/lib/hooks/useLocalStorage.ts b/apps/portal/src/lib/hooks/useLocalStorage.ts deleted file mode 100644 index aa018045..00000000 --- a/apps/portal/src/lib/hooks/useLocalStorage.ts +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { logger } from "@/lib/logger"; - -/** - * Hook for managing localStorage with SSR safety - */ -export function useLocalStorage( - key: string, - initialValue: T -): [T, (value: T | ((val: T) => T)) => void, () => void] { - // State to store our value - const [storedValue, setStoredValue] = useState(initialValue); - const [isClient, setIsClient] = useState(false); - - // Check if we're on the client side - useEffect(() => { - setIsClient(true); - }, []); - - // Get value from localStorage on client side - useEffect(() => { - if (!isClient) return; - - try { - const item = window.localStorage.getItem(key); - if (item) { - const parsed: unknown = JSON.parse(item); - setStoredValue(parsed as T); - } - } catch (error) { - logger.warn("Error reading localStorage key", { - key, - error: error instanceof Error ? error.message : String(error), - }); - } - }, [key, isClient]); - - // Return a wrapped version of useState's setter function that persists the new value to localStorage - const setValue = useCallback( - (value: T | ((val: T) => T)) => { - try { - // Allow value to be a function so we have the same API as useState - const valueToStore = value instanceof Function ? value(storedValue) : value; - - // Save state - setStoredValue(valueToStore); - - // Save to localStorage if on client - if (isClient) { - window.localStorage.setItem(key, JSON.stringify(valueToStore)); - } - } catch (error) { - logger.warn("Error setting localStorage key", { - key, - error: error instanceof Error ? error.message : String(error), - }); - } - }, - [key, storedValue, isClient] - ); - - // Function to remove the item from localStorage - const removeValue = useCallback(() => { - try { - setStoredValue(initialValue); - if (isClient) { - window.localStorage.removeItem(key); - } - } catch (error) { - logger.warn("Error removing localStorage key", { - key, - error: error instanceof Error ? error.message : String(error), - }); - } - }, [key, initialValue, isClient]); - - return [storedValue, setValue, removeValue]; -} diff --git a/apps/portal/src/lib/hooks/useMediaQuery.ts b/apps/portal/src/lib/hooks/useMediaQuery.ts deleted file mode 100644 index 574ea98b..00000000 --- a/apps/portal/src/lib/hooks/useMediaQuery.ts +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; - -/** - * Hook for responsive design with media queries - */ -export function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(false); - - useEffect(() => { - const media = window.matchMedia(query); - - // Set initial value - setMatches(media.matches); - - // Create event listener - const listener = (event: MediaQueryListEvent) => { - setMatches(event.matches); - }; - - // Add listener - media.addEventListener("change", listener); - - // Cleanup - return () => media.removeEventListener("change", listener); - }, [query]); - - return matches; -} - -// Common breakpoint hooks -export const useIsMobile = () => useMediaQuery("(max-width: 768px)"); -export const useIsTablet = () => useMediaQuery("(min-width: 769px) and (max-width: 1024px)"); -export const useIsDesktop = () => useMediaQuery("(min-width: 1025px)"); diff --git a/apps/portal/src/lib/hooks/useZodForm.ts b/apps/portal/src/lib/hooks/useZodForm.ts deleted file mode 100644 index 7c63fc14..00000000 --- a/apps/portal/src/lib/hooks/useZodForm.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Zod form utilities for React - * Provides predictable error and touched state handling for forms - */ - -import { useCallback, useMemo, useState } from "react"; -import type { FormEvent } from "react"; -import { ZodError, type ZodIssue, type ZodType } from "zod"; - -export type FormErrors<_TValues extends Record> = Record< - string, - string | undefined ->; -export type FormTouched<_TValues extends Record> = Record< - string, - boolean | undefined ->; - -export interface ZodFormOptions> { - schema: ZodType; - initialValues: TValues; - onSubmit?: (data: TValues) => Promise | void; -} - -export interface UseZodFormReturn> { - values: TValues; - errors: FormErrors; - touched: FormTouched; - submitError: string | null; - isSubmitting: boolean; - isValid: boolean; - setValue: (field: K, value: TValues[K]) => void; - setTouched: (field: K, touched: boolean) => void; - setTouchedField: (field: K, touched?: boolean) => void; - validate: () => boolean; - validateField: (field: K) => boolean; - handleSubmit: (event?: FormEvent) => Promise; - reset: () => void; -} - -function issuesToErrors>( - issues: ZodIssue[] -): FormErrors { - const nextErrors: FormErrors = {}; - - issues.forEach(issue => { - const [first, ...rest] = issue.path; - const key = issue.path.join("."); - - if (typeof first === "string" && nextErrors[first] === undefined) { - nextErrors[first] = issue.message; - } - - if (key) { - nextErrors[key] = issue.message; - - if (rest.length > 0) { - const topLevelKey = String(first); - if (nextErrors[topLevelKey] === undefined) { - nextErrors[topLevelKey] = issue.message; - } - } - } else if (nextErrors._form === undefined) { - nextErrors._form = issue.message; - } - }); - - return nextErrors; -} - -export function useZodForm>({ - schema, - initialValues, - onSubmit, -}: ZodFormOptions): UseZodFormReturn { - const [values, setValues] = useState(initialValues); - const [errors, setErrors] = useState>({}); - const [touched, setTouchedState] = useState>({}); - const [submitError, setSubmitError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - const clearFieldError = useCallback((field: keyof TValues) => { - const fieldKey = String(field); - setErrors(prev => { - const prefix = `${fieldKey}.`; - const hasDirectError = prev[fieldKey] !== undefined; - const hasNestedError = Object.keys(prev).some(key => key.startsWith(prefix)); - - if (!hasDirectError && !hasNestedError) { - return prev; - } - - const next: FormErrors = { ...prev }; - delete next[fieldKey]; - Object.keys(next).forEach(key => { - if (key.startsWith(prefix)) { - delete next[key]; - } - }); - return next; - }); - }, []); - - const validate = useCallback((): boolean => { - try { - schema.parse(values); - setErrors({}); - return true; - } catch (error) { - if (error instanceof ZodError) { - setErrors(issuesToErrors(error.issues)); - } - return false; - } - }, [schema, values]); - - const validateField = useCallback( - (field: K): boolean => { - const result = schema.safeParse(values); - - if (result.success) { - clearFieldError(field); - setErrors(prev => { - if (prev._form === undefined) { - return prev; - } - const next: FormErrors = { ...prev }; - delete next._form; - return next; - }); - return true; - } - - const fieldKey = String(field); - const relatedIssues = result.error.issues.filter(issue => issue.path[0] === field); - - setErrors(prev => { - const next: FormErrors = { ...prev }; - - if (relatedIssues.length > 0) { - const message = relatedIssues[0]?.message ?? ""; - next[fieldKey] = message; - relatedIssues.forEach(issue => { - const nestedKey = issue.path.join("."); - if (nestedKey) { - next[nestedKey] = issue.message; - } - }); - } else { - delete next[fieldKey]; - } - - const formLevelIssue = result.error.issues.find(issue => issue.path.length === 0); - if (formLevelIssue) { - next._form = formLevelIssue.message; - } else if (relatedIssues.length === 0) { - delete next._form; - } - - return next; - }); - - return relatedIssues.length === 0; - }, - [schema, values, clearFieldError] - ); - - const setValue = useCallback( - (field: K, value: TValues[K]): void => { - setValues(prev => ({ ...prev, [field]: value })); - clearFieldError(field); - }, - [clearFieldError] - ); - - const setTouched = useCallback((field: K, value: boolean): void => { - setTouchedState(prev => ({ ...prev, [String(field)]: value })); - }, []); - - const setTouchedField = useCallback( - (field: K, value: boolean = true): void => { - setTouched(field, value); - void validateField(field); - }, - [setTouched, validateField] - ); - - const handleSubmit = useCallback( - async (event?: FormEvent): Promise => { - event?.preventDefault(); - - if (!onSubmit) { - return; - } - - const valid = validate(); - if (!valid) { - return; - } - - setIsSubmitting(true); - setSubmitError(null); - setErrors(prev => { - if (prev._form === undefined) { - return prev; - } - const next: FormErrors = { ...prev }; - delete next._form; - return next; - }); - - try { - await onSubmit(values); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setSubmitError(message); - setErrors(prev => ({ ...prev, _form: message })); - } finally { - setIsSubmitting(false); - } - }, - [validate, onSubmit, values] - ); - - const reset = useCallback((): void => { - setValues(initialValues); - setErrors({}); - setTouchedState({}); - setSubmitError(null); - setIsSubmitting(false); - }, [initialValues]); - - const isValid = useMemo(() => Object.values(errors).every(error => !error), [errors]); - - // Memoize the return object to provide stable references. - // State values (values, errors, touched, etc.) will still trigger re-renders when they change, - // but the object identity remains stable, preventing issues when used in dependency arrays. - // Note: Callbacks (setValue, setTouched, etc.) are already stable via useCallback. - return useMemo( - () => ({ - values, - errors, - touched, - submitError, - isSubmitting, - isValid, - setValue, - setTouched, - setTouchedField, - validate, - validateField, - handleSubmit, - reset, - }), - [ - values, - errors, - touched, - submitError, - isSubmitting, - isValid, - setValue, - setTouched, - setTouchedField, - validate, - validateField, - handleSubmit, - reset, - ] - ); -} diff --git a/apps/portal/src/lib/logger.ts b/apps/portal/src/lib/logger.ts deleted file mode 100644 index 1c287c44..00000000 --- a/apps/portal/src/lib/logger.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Client-side logging utility - * - * Provides structured logging with appropriate levels - * and optional integration with error tracking services. - */ - -/* eslint-disable no-console -- Logger implementation requires direct console access */ - -interface LogMeta { - [key: string]: unknown; -} - -const formatMeta = (meta?: LogMeta): LogMeta | undefined => { - if (meta && typeof meta === "object") { - return meta; - } - return undefined; -}; - -class Logger { - private readonly isDevelopment = process.env.NODE_ENV === "development"; - - debug(message: string, meta?: LogMeta): void { - if (this.isDevelopment) { - console.debug(`[DEBUG] ${message}`, formatMeta(meta)); - } - } - - info(message: string, meta?: LogMeta): void { - if (this.isDevelopment) { - console.info(`[INFO] ${message}`, formatMeta(meta)); - } - } - - warn(message: string, meta?: LogMeta): void { - console.warn(`[WARN] ${message}`, formatMeta(meta)); - // Integration point: Add monitoring service (e.g., Datadog, New Relic) for production warnings - } - - error(message: string, error?: unknown, meta?: LogMeta): void { - console.error(`[ERROR] ${message}`, error ?? "", formatMeta(meta)); - // Integration point: Add error tracking service (e.g., Sentry, Bugsnag) for production errors - this.reportError(message, error, meta); - } - - private reportError(message: string, error?: unknown, meta?: LogMeta): void { - if (this.isDevelopment || typeof window === "undefined") { - return; - } - - // Placeholder for error tracking integration (e.g., Sentry, Datadog). - // Keep payload available for future wiring instead of dropping context. - const payload = { message, error, meta }; - void payload; - } - - /** - * Log API errors with additional context - */ - apiError(endpoint: string, error: unknown, meta?: LogMeta): void { - this.error(`API Error: ${endpoint}`, error, { - endpoint, - ...meta, - }); - } - - /** - * Log performance metrics - */ - performance(metric: string, duration: number, meta?: LogMeta): void { - if (this.isDevelopment) { - console.info(`[PERF] ${metric}: ${duration}ms`, formatMeta(meta)); - } - } -} - -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/providers.tsx b/apps/portal/src/lib/providers.tsx deleted file mode 100644 index 2a1b7e70..00000000 --- a/apps/portal/src/lib/providers.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** - * React Query Provider - * Simple provider setup for TanStack Query with CSP nonce support - */ - -"use client"; - -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { useEffect, useState } from "react"; -import { isApiError } from "@/lib/api/runtime/client"; -import { useAuthStore } from "@/features/auth/stores/auth.store"; - -interface QueryProviderProps { - children: React.ReactNode; - nonce?: string; -} - -export function QueryProvider({ children }: QueryProviderProps) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, // 5 minutes - gcTime: 10 * 60 * 1000, // 10 minutes - refetchOnWindowFocus: false, // Prevent excessive refetches in development - refetchOnMount: true, // Only refetch if data is stale (>5 min old) - refetchOnReconnect: true, // Only refetch on reconnect if stale - retry: (failureCount, error: unknown) => { - if (isApiError(error)) { - const status = error.response?.status; - // Don't retry on 4xx errors (client errors) - if (status && status >= 400 && status < 500) { - return false; - } - const body = error.body as Record | undefined; - const code = typeof body?.code === "string" ? body.code : undefined; - // Don't retry on auth errors or rate limits - if (code === "AUTHENTICATION_REQUIRED" || code === "FORBIDDEN") { - return false; - } - } - return failureCount < 3; - }, - retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff - }, - }, - }) - ); - - // Security + correctness: clear cached queries on logout so a previous user's - // account-scoped data cannot remain in memory. - useEffect(() => { - const unsubscribe = useAuthStore.subscribe((state, prevState) => { - if (prevState.isAuthenticated && !state.isAuthenticated) { - queryClient.clear(); - } - }); - - return unsubscribe; - }, [queryClient]); - - return ( - - {children} - {process.env.NODE_ENV === "development" && } - - ); -} diff --git a/apps/portal/src/lib/services/currency.service.ts b/apps/portal/src/lib/services/currency.service.ts deleted file mode 100644 index 6f7475ae..00000000 --- a/apps/portal/src/lib/services/currency.service.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { apiClient, getDataOrThrow } from "@/lib/api"; -import { FALLBACK_CURRENCY, type Currency } from "@customer-portal/domain/billing"; - -export { FALLBACK_CURRENCY }; - -export const currencyService = { - async getDefaultCurrency(): Promise { - const response = await apiClient.GET("/api/currency/default"); - return getDataOrThrow(response, "Failed to get default currency"); - }, - - async getAllCurrencies(): Promise { - const response = await apiClient.GET("/api/currency/all"); - return getDataOrThrow(response, "Failed to get currencies"); - }, -}; diff --git a/apps/portal/src/lib/utils/cn.ts b/apps/portal/src/lib/utils/cn.ts deleted file mode 100644 index 1a92305f..00000000 --- a/apps/portal/src/lib/utils/cn.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; - -/** - * Utility for merging Tailwind CSS classes - * Combines clsx for conditional classes and tailwind-merge for deduplication - */ -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/apps/portal/src/lib/utils/date.ts b/apps/portal/src/lib/utils/date.ts deleted file mode 100644 index 7862e0d1..00000000 --- a/apps/portal/src/lib/utils/date.ts +++ /dev/null @@ -1,72 +0,0 @@ -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/error-handling.ts b/apps/portal/src/lib/utils/error-handling.ts deleted file mode 100644 index c716d889..00000000 --- a/apps/portal/src/lib/utils/error-handling.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Unified Error Handling for Portal - * - * Clean, simple error handling that uses shared error codes from domain package. - * Provides consistent error parsing and user-friendly messages. - */ - -import { ApiError as ClientApiError, isApiError } from "@/lib/api"; -import { - ErrorCode, - ErrorMessages, - ErrorMetadata, - mapHttpStatusToErrorCode, - type ErrorCodeType, -} from "@customer-portal/domain/common"; -import { parseDomainError } from "@/lib/api"; - -// ============================================================================ -// Types -// ============================================================================ - -export interface ParsedError { - code: ErrorCodeType; - message: string; - shouldLogout: boolean; - shouldRetry: boolean; -} - -// ============================================================================ -// Error Parsing -// ============================================================================ - -/** - * Parse any error into a structured format with code and user-friendly message. - * This is the main entry point for error handling. - */ -export function parseError(error: unknown): ParsedError { - // Handle API client errors - if (isApiError(error)) { - return parseApiError(error); - } - - // Handle network/fetch errors - if (error instanceof Error) { - return parseNativeError(error); - } - - // Handle string errors - if (typeof error === "string") { - return { - code: ErrorCode.UNKNOWN, - message: error, - shouldLogout: false, - shouldRetry: true, - }; - } - - // Unknown error type - return { - code: ErrorCode.UNKNOWN, - message: ErrorMessages[ErrorCode.UNKNOWN], - shouldLogout: false, - shouldRetry: true, - }; -} - -/** - * Parse API client error - */ -function parseApiError(error: ClientApiError): ParsedError { - const body = error.body; - const status = error.response?.status; - - const domainError = parseDomainError(body); - if (domainError) { - const rawCode = domainError.error.code; - const resolvedCode: ErrorCodeType = Object.prototype.hasOwnProperty.call(ErrorMetadata, rawCode) - ? (rawCode as ErrorCodeType) - : ErrorCode.UNKNOWN; - - const metadata = ErrorMetadata[resolvedCode] ?? ErrorMetadata[ErrorCode.UNKNOWN]; - return { - code: resolvedCode, - message: domainError.error.message, - shouldLogout: metadata.shouldLogout, - shouldRetry: metadata.shouldRetry, - }; - } - - // Fall back to status code mapping - const code = mapHttpStatusToErrorCode(status); - const metadata = ErrorMetadata[code]; - return { - code, - message: error.message || ErrorMessages[code], - shouldLogout: metadata.shouldLogout, - shouldRetry: metadata.shouldRetry, - }; -} - -/** - * Parse native JavaScript errors (network, timeout, etc.) - */ -function parseNativeError(error: Error): ParsedError { - // Network errors - if (error.name === "TypeError" && error.message.includes("fetch")) { - return { - code: ErrorCode.NETWORK_ERROR, - message: ErrorMessages[ErrorCode.NETWORK_ERROR], - shouldLogout: false, - shouldRetry: true, - }; - } - - // Timeout errors - if (error.name === "AbortError") { - return { - code: ErrorCode.TIMEOUT, - message: ErrorMessages[ErrorCode.TIMEOUT], - shouldLogout: false, - shouldRetry: true, - }; - } - - return { - code: ErrorCode.UNKNOWN, - message: error.message || ErrorMessages[ErrorCode.UNKNOWN], - shouldLogout: false, - shouldRetry: true, - }; -} - -// ============================================================================ -// Convenience Functions -// ============================================================================ - -/** - * Get user-friendly error message from any error - */ -export function getErrorMessage(error: unknown): string { - return parseError(error).message; -} - -/** - * Check if error should trigger logout - */ -export function shouldLogout(error: unknown): boolean { - return parseError(error).shouldLogout; -} - -/** - * Check if error can be retried - */ -export function canRetry(error: unknown): boolean { - return parseError(error).shouldRetry; -} - -/** - * Get error code from any error - */ -export function getErrorCode(error: unknown): ErrorCodeType { - return parseError(error).code; -} - -// ============================================================================ -// Re-exports from domain package for convenience -// ============================================================================ - -export { - ErrorCode, - ErrorMessages, - ErrorMetadata, - type ErrorCodeType, -} from "@customer-portal/domain/common"; diff --git a/apps/portal/src/lib/utils/index.ts b/apps/portal/src/lib/utils/index.ts deleted file mode 100644 index e4c8f793..00000000 --- a/apps/portal/src/lib/utils/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -export { cn } from "./cn"; -export { - formatIsoDate, - formatIsoRelative, - formatIsoMonthDay, - isSameDay, - isToday, - isYesterday, - type FormatDateFallbackOptions, -} from "./date"; -export { - parseError, - getErrorMessage, - shouldLogout, - canRetry, - getErrorCode, - ErrorCode, - ErrorMessages, - type ParsedError, - type ErrorCodeType, -} from "./error-handling"; diff --git a/packages/domain/billing/schema.ts b/packages/domain/billing/schema.ts index 03e62687..b990a759 100644 --- a/packages/domain/billing/schema.ts +++ b/packages/domain/billing/schema.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { INVOICE_PAGINATION } from "./constants.js"; +import { INVOICE_STATUS } from "./contract.js"; // ============================================================================ // Currency (Domain Model) @@ -27,17 +28,9 @@ export const currencySchema = z.object({ export type Currency = z.infer; -// Invoice Status Schema -export const invoiceStatusSchema = z.enum([ - "Draft", - "Pending", - "Paid", - "Unpaid", - "Overdue", - "Cancelled", - "Refunded", - "Collections", -]); +// Invoice Status Schema - derived from contract constants +const INVOICE_STATUS_VALUES = Object.values(INVOICE_STATUS) as [string, ...string[]]; +export const invoiceStatusSchema = z.enum(INVOICE_STATUS_VALUES); // Invoice Item Schema export const invoiceItemSchema = z.object({ diff --git a/packages/domain/checkout/contract.ts b/packages/domain/checkout/contract.ts new file mode 100644 index 00000000..5c11d2f5 --- /dev/null +++ b/packages/domain/checkout/contract.ts @@ -0,0 +1,43 @@ +/** + * Checkout Domain - Contract + * + * Business constants and helpers for the checkout flow. + */ + +// Re-export ORDER_TYPE from orders domain for convenience +export { ORDER_TYPE, type OrderTypeValue } from "../orders/contract.js"; + +/** + * Checkout-specific order types (subset of ORDER_TYPE, excludes "Other") + * These are the types that can be ordered through checkout. + */ +export const CHECKOUT_ORDER_TYPE = { + INTERNET: "Internet", + SIM: "SIM", + VPN: "VPN", +} as const; + +export type CheckoutOrderTypeValue = (typeof CHECKOUT_ORDER_TYPE)[keyof typeof CHECKOUT_ORDER_TYPE]; + +/** + * Convert legacy uppercase order type to PascalCase + * Used for migrating old localStorage data + */ +export function normalizeOrderType(value: unknown): CheckoutOrderTypeValue | null { + if (typeof value !== "string") return null; + + const upper = value.trim().toUpperCase(); + switch (upper) { + case "INTERNET": + return "Internet"; + case "SIM": + return "SIM"; + case "VPN": + return "VPN"; + default: + return null; + } +} + +// Re-export types from schema +export type { OrderType, PriceBreakdownItem, CartItem } from "./schema.js"; diff --git a/packages/domain/checkout/index.ts b/packages/domain/checkout/index.ts index 09f81c02..942e859a 100644 --- a/packages/domain/checkout/index.ts +++ b/packages/domain/checkout/index.ts @@ -4,4 +4,21 @@ * Types and schemas for unified checkout flow. */ -export * from "./schema.js"; +// Contracts (constants, helpers) +export { + ORDER_TYPE, + CHECKOUT_ORDER_TYPE, + normalizeOrderType, + type OrderTypeValue, + type CheckoutOrderTypeValue, +} from "./contract.js"; + +// Schemas and schema-derived types +export { + checkoutOrderTypeSchema, + priceBreakdownItemSchema, + cartItemSchema, + type OrderType, + type PriceBreakdownItem, + type CartItem, +} from "./schema.js"; diff --git a/packages/domain/checkout/schema.ts b/packages/domain/checkout/schema.ts index 8e7954e2..31db8a84 100644 --- a/packages/domain/checkout/schema.ts +++ b/packages/domain/checkout/schema.ts @@ -6,40 +6,19 @@ */ import { z } from "zod"; +import { CHECKOUT_ORDER_TYPE } from "./contract.js"; // ============================================================================ // Order Type Schema // ============================================================================ +const CHECKOUT_ORDER_TYPE_VALUES = Object.values(CHECKOUT_ORDER_TYPE) as [string, ...string[]]; + /** * Checkout order types - uses PascalCase to match Salesforce/BFF contracts * @see packages/domain/orders/contract.ts ORDER_TYPE for canonical values */ -export const checkoutOrderTypeSchema = z.enum(["Internet", "SIM", "VPN"]); - -// ============================================================================ -// Order Type Helpers -// ============================================================================ - -/** - * Convert legacy uppercase order type to PascalCase - * Used for migrating old localStorage data - */ -export function normalizeOrderType(value: unknown): z.infer | null { - if (typeof value !== "string") return null; - - const upper = value.trim().toUpperCase(); - switch (upper) { - case "INTERNET": - return "Internet"; - case "SIM": - return "SIM"; - case "VPN": - return "VPN"; - default: - return null; - } -} +export const checkoutOrderTypeSchema = z.enum(CHECKOUT_ORDER_TYPE_VALUES); // ============================================================================ // Price Breakdown Schema diff --git a/packages/domain/notifications/contract.ts b/packages/domain/notifications/contract.ts new file mode 100644 index 00000000..45fe439f --- /dev/null +++ b/packages/domain/notifications/contract.ts @@ -0,0 +1,173 @@ +/** + * Notifications Contract + * + * Business constants, enums, and templates for in-app notifications. + */ + +// ============================================================================= +// Enums +// ============================================================================= + +export const NOTIFICATION_TYPE = { + ELIGIBILITY_ELIGIBLE: "ELIGIBILITY_ELIGIBLE", + ELIGIBILITY_INELIGIBLE: "ELIGIBILITY_INELIGIBLE", + VERIFICATION_VERIFIED: "VERIFICATION_VERIFIED", + VERIFICATION_REJECTED: "VERIFICATION_REJECTED", + ORDER_APPROVED: "ORDER_APPROVED", + ORDER_ACTIVATED: "ORDER_ACTIVATED", + ORDER_FAILED: "ORDER_FAILED", + CANCELLATION_SCHEDULED: "CANCELLATION_SCHEDULED", + CANCELLATION_COMPLETE: "CANCELLATION_COMPLETE", + PAYMENT_METHOD_EXPIRING: "PAYMENT_METHOD_EXPIRING", + INVOICE_DUE: "INVOICE_DUE", + SYSTEM_ANNOUNCEMENT: "SYSTEM_ANNOUNCEMENT", +} as const; + +export type NotificationTypeValue = (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE]; + +export const NOTIFICATION_SOURCE = { + SALESFORCE: "SALESFORCE", + WHMCS: "WHMCS", + PORTAL: "PORTAL", + SYSTEM: "SYSTEM", +} as const; + +export type NotificationSourceValue = + (typeof NOTIFICATION_SOURCE)[keyof typeof NOTIFICATION_SOURCE]; + +// ============================================================================= +// Notification Templates +// ============================================================================= + +export interface NotificationTemplate { + type: NotificationTypeValue; + title: string; + message: string; + actionUrl?: string; + actionLabel?: string; + priority: "low" | "medium" | "high"; +} + +export const NOTIFICATION_TEMPLATES: Record = { + [NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE]: { + type: NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE, + title: "Good news! Internet service is available", + message: + "We've confirmed internet service is available at your address. You can now select a plan and complete your order.", + actionUrl: "/account/services/internet", + actionLabel: "View Plans", + priority: "high", + }, + [NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE]: { + type: NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE, + title: "Internet service not available", + message: + "Unfortunately, internet service is not currently available at your address. We'll notify you if this changes.", + actionUrl: "/account/support", + actionLabel: "Contact Support", + priority: "high", + }, + [NOTIFICATION_TYPE.VERIFICATION_VERIFIED]: { + type: NOTIFICATION_TYPE.VERIFICATION_VERIFIED, + title: "ID verification complete", + message: "Your identity has been verified. You can now complete your order.", + actionUrl: "/account/order", + actionLabel: "Continue Checkout", + priority: "high", + }, + [NOTIFICATION_TYPE.VERIFICATION_REJECTED]: { + type: NOTIFICATION_TYPE.VERIFICATION_REJECTED, + title: "ID verification requires attention", + message: "We couldn't verify your ID. Please review the feedback and resubmit.", + actionUrl: "/account/settings/verification", + actionLabel: "Resubmit", + priority: "high", + }, + [NOTIFICATION_TYPE.ORDER_APPROVED]: { + type: NOTIFICATION_TYPE.ORDER_APPROVED, + title: "Order approved", + message: "Your order has been approved and is being processed.", + actionUrl: "/account/orders", + actionLabel: "View Order", + priority: "medium", + }, + [NOTIFICATION_TYPE.ORDER_ACTIVATED]: { + type: NOTIFICATION_TYPE.ORDER_ACTIVATED, + title: "Service activated", + message: "Your service is now active and ready to use.", + actionUrl: "/account/services", + actionLabel: "View Service", + priority: "high", + }, + [NOTIFICATION_TYPE.ORDER_FAILED]: { + type: NOTIFICATION_TYPE.ORDER_FAILED, + title: "Order requires attention", + message: "There was an issue processing your order. Please contact support.", + actionUrl: "/account/support", + actionLabel: "Contact Support", + priority: "high", + }, + [NOTIFICATION_TYPE.CANCELLATION_SCHEDULED]: { + type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED, + title: "Cancellation scheduled", + message: "Your cancellation request has been received and scheduled.", + actionUrl: "/account/services", + actionLabel: "View Details", + priority: "medium", + }, + [NOTIFICATION_TYPE.CANCELLATION_COMPLETE]: { + type: NOTIFICATION_TYPE.CANCELLATION_COMPLETE, + title: "Service cancelled", + message: "Your service has been successfully cancelled.", + actionUrl: "/account/services", + actionLabel: "View Details", + priority: "medium", + }, + [NOTIFICATION_TYPE.PAYMENT_METHOD_EXPIRING]: { + type: NOTIFICATION_TYPE.PAYMENT_METHOD_EXPIRING, + title: "Payment method expiring soon", + message: + "Your payment method is expiring soon. Please update it to avoid service interruption.", + actionUrl: "/account/billing/payments", + actionLabel: "Update Payment", + priority: "high", + }, + [NOTIFICATION_TYPE.INVOICE_DUE]: { + type: NOTIFICATION_TYPE.INVOICE_DUE, + title: "Invoice due", + message: "You have an invoice due. Please make a payment to keep your service active.", + actionUrl: "/account/billing/invoices", + actionLabel: "Pay Now", + priority: "high", + }, + [NOTIFICATION_TYPE.SYSTEM_ANNOUNCEMENT]: { + type: NOTIFICATION_TYPE.SYSTEM_ANNOUNCEMENT, + title: "System announcement", + message: "Important information about your service.", + priority: "low", + }, +}; + +/** + * Get notification template by type with optional overrides + */ +export function getNotificationTemplate( + type: NotificationTypeValue, + overrides?: Partial +): NotificationTemplate { + const template = NOTIFICATION_TEMPLATES[type]; + if (!template) { + throw new Error(`Unknown notification type: ${type}`); + } + return { ...template, ...overrides }; +} + +// Re-export types from schema +export type { + Notification, + CreateNotificationRequest, + NotificationListResponse, + NotificationUnreadCountResponse, + NotificationQuery, + NotificationIdParam, +} from "./schema.js"; diff --git a/packages/domain/notifications/index.ts b/packages/domain/notifications/index.ts index 36c9a42b..32702afd 100644 --- a/packages/domain/notifications/index.ts +++ b/packages/domain/notifications/index.ts @@ -5,26 +5,27 @@ * Used for in-app notifications synced with Salesforce email triggers. */ +// Contracts (enums, constants, templates) export { - // Enums NOTIFICATION_TYPE, NOTIFICATION_SOURCE, - type NotificationTypeValue, - type NotificationSourceValue, - // Templates NOTIFICATION_TEMPLATES, getNotificationTemplate, - // Schemas + type NotificationTypeValue, + type NotificationSourceValue, + type NotificationTemplate, +} from "./contract.js"; + +// Schemas and schema-derived types +export { notificationSchema, createNotificationRequestSchema, notificationListResponseSchema, notificationUnreadCountResponseSchema, notificationQuerySchema, notificationIdParamSchema, - // Types type Notification, type CreateNotificationRequest, - type NotificationTemplate, type NotificationListResponse, type NotificationUnreadCountResponse, type NotificationQuery, diff --git a/packages/domain/notifications/schema.ts b/packages/domain/notifications/schema.ts index 14bc783f..35dfc376 100644 --- a/packages/domain/notifications/schema.ts +++ b/packages/domain/notifications/schema.ts @@ -5,164 +5,7 @@ */ import { z } from "zod"; - -// ============================================================================= -// Enums -// ============================================================================= - -export const NOTIFICATION_TYPE = { - ELIGIBILITY_ELIGIBLE: "ELIGIBILITY_ELIGIBLE", - ELIGIBILITY_INELIGIBLE: "ELIGIBILITY_INELIGIBLE", - VERIFICATION_VERIFIED: "VERIFICATION_VERIFIED", - VERIFICATION_REJECTED: "VERIFICATION_REJECTED", - ORDER_APPROVED: "ORDER_APPROVED", - ORDER_ACTIVATED: "ORDER_ACTIVATED", - ORDER_FAILED: "ORDER_FAILED", - CANCELLATION_SCHEDULED: "CANCELLATION_SCHEDULED", - CANCELLATION_COMPLETE: "CANCELLATION_COMPLETE", - PAYMENT_METHOD_EXPIRING: "PAYMENT_METHOD_EXPIRING", - INVOICE_DUE: "INVOICE_DUE", - SYSTEM_ANNOUNCEMENT: "SYSTEM_ANNOUNCEMENT", -} as const; - -export type NotificationTypeValue = (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE]; - -export const NOTIFICATION_SOURCE = { - SALESFORCE: "SALESFORCE", - WHMCS: "WHMCS", - PORTAL: "PORTAL", - SYSTEM: "SYSTEM", -} as const; - -export type NotificationSourceValue = - (typeof NOTIFICATION_SOURCE)[keyof typeof NOTIFICATION_SOURCE]; - -// ============================================================================= -// Notification Templates -// ============================================================================= - -export interface NotificationTemplate { - type: NotificationTypeValue; - title: string; - message: string; - actionUrl?: string; - actionLabel?: string; - priority: "low" | "medium" | "high"; -} - -export const NOTIFICATION_TEMPLATES: Record = { - [NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE]: { - type: NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE, - title: "Good news! Internet service is available", - message: - "We've confirmed internet service is available at your address. You can now select a plan and complete your order.", - actionUrl: "/account/services/internet", - actionLabel: "View Plans", - priority: "high", - }, - [NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE]: { - type: NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE, - title: "Internet service not available", - message: - "Unfortunately, internet service is not currently available at your address. We'll notify you if this changes.", - actionUrl: "/account/support", - actionLabel: "Contact Support", - priority: "high", - }, - [NOTIFICATION_TYPE.VERIFICATION_VERIFIED]: { - type: NOTIFICATION_TYPE.VERIFICATION_VERIFIED, - title: "ID verification complete", - message: "Your identity has been verified. You can now complete your order.", - actionUrl: "/account/order", - actionLabel: "Continue Checkout", - priority: "high", - }, - [NOTIFICATION_TYPE.VERIFICATION_REJECTED]: { - type: NOTIFICATION_TYPE.VERIFICATION_REJECTED, - title: "ID verification requires attention", - message: "We couldn't verify your ID. Please review the feedback and resubmit.", - actionUrl: "/account/settings/verification", - actionLabel: "Resubmit", - priority: "high", - }, - [NOTIFICATION_TYPE.ORDER_APPROVED]: { - type: NOTIFICATION_TYPE.ORDER_APPROVED, - title: "Order approved", - message: "Your order has been approved and is being processed.", - actionUrl: "/account/orders", - actionLabel: "View Order", - priority: "medium", - }, - [NOTIFICATION_TYPE.ORDER_ACTIVATED]: { - type: NOTIFICATION_TYPE.ORDER_ACTIVATED, - title: "Service activated", - message: "Your service is now active and ready to use.", - actionUrl: "/account/services", - actionLabel: "View Service", - priority: "high", - }, - [NOTIFICATION_TYPE.ORDER_FAILED]: { - type: NOTIFICATION_TYPE.ORDER_FAILED, - title: "Order requires attention", - message: "There was an issue processing your order. Please contact support.", - actionUrl: "/account/support", - actionLabel: "Contact Support", - priority: "high", - }, - [NOTIFICATION_TYPE.CANCELLATION_SCHEDULED]: { - type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED, - title: "Cancellation scheduled", - message: "Your cancellation request has been received and scheduled.", - actionUrl: "/account/services", - actionLabel: "View Details", - priority: "medium", - }, - [NOTIFICATION_TYPE.CANCELLATION_COMPLETE]: { - type: NOTIFICATION_TYPE.CANCELLATION_COMPLETE, - title: "Service cancelled", - message: "Your service has been successfully cancelled.", - actionUrl: "/account/services", - actionLabel: "View Details", - priority: "medium", - }, - [NOTIFICATION_TYPE.PAYMENT_METHOD_EXPIRING]: { - type: NOTIFICATION_TYPE.PAYMENT_METHOD_EXPIRING, - title: "Payment method expiring soon", - message: - "Your payment method is expiring soon. Please update it to avoid service interruption.", - actionUrl: "/account/billing/payments", - actionLabel: "Update Payment", - priority: "high", - }, - [NOTIFICATION_TYPE.INVOICE_DUE]: { - type: NOTIFICATION_TYPE.INVOICE_DUE, - title: "Invoice due", - message: "You have an invoice due. Please make a payment to keep your service active.", - actionUrl: "/account/billing/invoices", - actionLabel: "Pay Now", - priority: "high", - }, - [NOTIFICATION_TYPE.SYSTEM_ANNOUNCEMENT]: { - type: NOTIFICATION_TYPE.SYSTEM_ANNOUNCEMENT, - title: "System announcement", - message: "Important information about your service.", - priority: "low", - }, -}; - -/** - * Get notification template by type with optional overrides - */ -export function getNotificationTemplate( - type: NotificationTypeValue, - overrides?: Partial -): NotificationTemplate { - const template = NOTIFICATION_TEMPLATES[type]; - if (!template) { - throw new Error(`Unknown notification type: ${type}`); - } - return { ...template, ...overrides }; -} +import { NOTIFICATION_TYPE, NOTIFICATION_SOURCE } from "./contract.js"; // ============================================================================= // Schemas