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 <noreply@anthropic.com>
This commit is contained in:
barsa 2026-01-13 14:25:14 +09:00
parent 90958d5e1d
commit f447ba1800
38 changed files with 333 additions and 2008 deletions

View File

@ -1,4 +1,4 @@
import { Module, forwardRef } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { FreebitOrchestratorService } from "./services/freebit-orchestrator.service.js"; import { FreebitOrchestratorService } from "./services/freebit-orchestrator.service.js";
import { FreebitMapperService } from "./services/freebit-mapper.service.js"; import { FreebitMapperService } from "./services/freebit-mapper.service.js";
import { FreebitOperationsService } from "./services/freebit-operations.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 { FreebitVoiceService } from "./services/freebit-voice.service.js";
import { FreebitCancellationService } from "./services/freebit-cancellation.service.js"; import { FreebitCancellationService } from "./services/freebit-cancellation.service.js";
import { FreebitEsimService } from "./services/freebit-esim.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({ @Module({
imports: [forwardRef(() => SimManagementModule)], imports: [VoiceOptionsModule],
providers: [ providers: [
// Core services // Core services
FreebitClientService, FreebitClientService,

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject, Optional } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import type { import type {
FreebitAccountDetailsResponse, FreebitAccountDetailsResponse,
@ -8,13 +8,15 @@ import type {
SimUsage, SimUsage,
SimTopUpHistory, SimTopUpHistory,
} from "../interfaces/freebit.types.js"; } 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() @Injectable()
export class FreebitMapperService { export class FreebitMapperService {
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @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 { private parseOptionFlag(value: unknown, defaultValue: boolean = false): boolean {

View File

@ -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 { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { FreebitClientService } from "./freebit-client.service.js"; import { FreebitClientService } from "./freebit-client.service.js";
import { FreebitRateLimiterService } from "./freebit-rate-limiter.service.js"; import { FreebitRateLimiterService } from "./freebit-rate-limiter.service.js";
import { FreebitAccountService } from "./freebit-account.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 { import type {
FreebitVoiceOptionSettings, FreebitVoiceOptionSettings,
FreebitVoiceOptionRequest, FreebitVoiceOptionRequest,
@ -30,7 +30,9 @@ export class FreebitVoiceService {
private readonly rateLimiter: FreebitRateLimiterService, private readonly rateLimiter: FreebitRateLimiterService,
private readonly accountService: FreebitAccountService, private readonly accountService: FreebitAccountService,
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: SimVoiceOptionsService @Optional()
@Inject("SimVoiceOptionsService")
private readonly voiceOptionsService?: VoiceOptionsService
) {} ) {}
/** /**

View File

@ -1,4 +1,5 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SubscriptionsService } from "../../subscriptions.service.js"; import { SubscriptionsService } from "../../subscriptions.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -13,6 +14,7 @@ import {
export class SimValidationService { export class SimValidationService {
constructor( constructor(
private readonly subscriptionsService: SubscriptionsService, private readonly subscriptionsService: SubscriptionsService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -40,22 +42,28 @@ export class SimValidationService {
// Extract SIM account identifier (using domain function) // Extract SIM account identifier (using domain function)
let account = extractSimAccountFromSubscription(subscription); 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) { if (!account) {
// Use the specific test SIM number that should exist in the test environment const testSimAccount = this.configService.get<string>("TEST_SIM_ACCOUNT");
account = "02000331144508";
this.logger.warn( if (testSimAccount) {
`No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`, account = testSimAccount;
{ this.logger.warn(
userId, `No SIM account identifier found for subscription ${subscriptionId}, using TEST_SIM_ACCOUNT fallback`,
subscriptionId, {
productName: subscription.productName, userId,
domain: subscription.domain, subscriptionId,
customFields: subscription.customFields ? Object.keys(subscription.customFields) : [], productName: subscription.productName,
note: "Using known test SIM number 02000331144508 - should exist in Freebit test environment", 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) // Clean up the account format (using domain function)
@ -102,9 +110,9 @@ export class SimValidationService {
subscriptionId subscriptionId
); );
// Check for specific SIM data // Check for specific SIM data (from config or use defaults for testing)
const expectedSimNumber = "02000331144508"; const expectedSimNumber = this.configService.get<string>("TEST_SIM_ACCOUNT", "");
const expectedEid = "89049032000001000000043598005455"; const expectedEid = this.configService.get<string>("TEST_SIM_EID", "");
const foundSimNumber = Object.entries(subscription.customFields || {}).find( const foundSimNumber = Object.entries(subscription.customFields || {}).find(
([, value]) => ([, value]) =>

View File

@ -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 { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.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 { SimActionRunnerService } from "./services/sim-action-runner.service.js";
import { SimManagementQueueService } from "./queue/sim-management.queue.js"; import { SimManagementQueueService } from "./queue/sim-management.queue.js";
import { SimManagementProcessor } from "./queue/sim-management.processor.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 { SimCallHistoryService } from "./services/sim-call-history.service.js";
import { ServicesModule } from "@bff/modules/services/services.module.js"; import { ServicesModule } from "@bff/modules/services/services.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-options/index.js";
@Module({ @Module({
imports: [ imports: [
forwardRef(() => FreebitModule), FreebitModule,
WhmcsModule, WhmcsModule,
SalesforceModule, SalesforceModule,
MappingsModule, MappingsModule,
@ -44,6 +44,7 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
SftpModule, SftpModule,
NotificationsModule, NotificationsModule,
SecurityModule, SecurityModule,
VoiceOptionsModule,
], ],
// SimController is registered in SubscriptionsModule to ensure route order // SimController is registered in SubscriptionsModule to ensure route order
// (more specific routes like :id/sim must be registered before :id) // (more specific routes like :id/sim must be registered before :id)
@ -58,7 +59,6 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
SimValidationService, SimValidationService,
SimNotificationService, SimNotificationService,
SimApiNotificationService, SimApiNotificationService,
SimVoiceOptionsService,
SimDetailsService, SimDetailsService,
SimUsageService, SimUsageService,
SimTopUpService, SimTopUpService,
@ -73,10 +73,10 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
SimManagementQueueService, SimManagementQueueService,
SimManagementProcessor, SimManagementProcessor,
SimCallHistoryService, SimCallHistoryService,
// Export with token for optional injection in Freebit module // Backwards compatibility alias: SimVoiceOptionsService -> VoiceOptionsService
{ {
provide: "SimVoiceOptionsService", provide: "SimVoiceOptionsService",
useExisting: SimVoiceOptionsService, useExisting: VoiceOptionsService,
}, },
], ],
exports: [ exports: [
@ -96,9 +96,10 @@ import { NotificationsModule } from "@bff/modules/notifications/notifications.mo
SimScheduleService, SimScheduleService,
SimActionRunnerService, SimActionRunnerService,
SimManagementQueueService, SimManagementQueueService,
SimVoiceOptionsService,
SimCallHistoryService, SimCallHistoryService,
"SimVoiceOptionsService", // Export the token // VoiceOptionsService is exported from VoiceOptionsModule
// Backwards compatibility: re-export the token
"SimVoiceOptionsService",
], ],
}) })
export class SimManagementModule {} export class SimManagementModule {}

View File

@ -0,0 +1,5 @@
export { VoiceOptionsModule } from "./voice-options.module.js";
export {
VoiceOptionsService,
type VoiceOptionsSettings,
} from "./services/voice-options.service.js";

View File

@ -10,7 +10,7 @@ export interface VoiceOptionsSettings {
} }
@Injectable() @Injectable()
export class SimVoiceOptionsService { export class VoiceOptionsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger

View File

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

View File

@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import "./globals.css"; import "./globals.css";
import { QueryProvider } from "@/lib/providers"; import { QueryProvider } from "@/core/providers";
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning"; import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
export const metadata: Metadata = { export const metadata: Metadata = {

View File

@ -1,5 +1,5 @@
"use client"; "use client";
import { logger } from "@/lib/logger"; import { logger } from "@/core/logger";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useAuthSession } from "@/features/auth/stores/auth.store"; import { useAuthSession } from "@/features/auth/stores/auth.store";

View File

@ -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<void> {
// 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<string, unknown>) => ["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<string, unknown>) => ["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<string, unknown>) =>
["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<string, unknown>) => ["support", "cases", params] as const,
case: (id: string) => ["support", "case", id] as const,
},
currency: {
default: () => ["currency", "default"] as const,
},
} as const;

View File

@ -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<T> = {
data?: T;
error?: unknown;
};
/**
* Extract data from API response or return null.
* Useful for optional data handling.
*/
export function getNullableData<T>(response: ApiResponse<T>): T | null {
if (response.error || response.data === undefined) {
return null;
}
return response.data;
}
/**
* Extract data from API response or throw error.
*/
export function getDataOrThrow<T>(response: ApiResponse<T>, 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<T>(response: ApiResponse<T>, defaultValue: T): T {
return response.data ?? defaultValue;
}
/**
* Check if response has an error.
*/
export function hasError<T>(response: ApiResponse<T>): boolean {
return !!response.error;
}
/**
* Check if response has data.
*/
export function hasData<T>(response: ApiResponse<T>): 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;
}

View File

@ -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<string, string | number>;
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<string, string>;
signal?: AbortSignal;
credentials?: RequestCredentials;
disableCsrf?: boolean;
}
export type AuthHeaderResolver = () => string | undefined;
export interface CreateClientOptions {
baseUrl?: string;
getAuthHeader?: AuthHeaderResolver;
handleError?: (response: Response) => void | Promise<void>;
enableCsrf?: boolean;
}
type ApiMethod = <T = unknown>(path: string, options?: RequestOptions) => Promise<ApiResponse<T>>;
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<unknown> => {
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<string> | null = null;
constructor(private readonly baseUrl: string) {}
async getToken(): Promise<string> {
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<string> {
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<HttpMethod>(["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 <T>(
method: HttpMethod,
path: string,
opts: RequestOptions = {}
): Promise<ApiResponse<T>> => {
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;
}

View File

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

View File

@ -1,33 +0,0 @@
export type UnauthorizedDetail = {
url?: string;
status?: number;
};
export type UnauthorizedListener = (detail: UnauthorizedDetail) => void;
const listeners = new Set<UnauthorizedListener>();
/**
* 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.
}
}
}

View File

@ -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<string, string>();
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());
}

View File

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

View File

@ -1,4 +0,0 @@
export { useLocalStorage } from "./useLocalStorage";
export { useDebounce } from "./useDebounce";
export { useMediaQuery, useIsMobile, useIsTablet, useIsDesktop } from "./useMediaQuery";
export { useZodForm } from "./useZodForm";

View File

@ -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<Currency>({
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,
};
}

View File

@ -1,22 +0,0 @@
"use client";
import { useState, useEffect } from "react";
/**
* Hook that debounces a value
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

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

View File

@ -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<T>(
key: string,
initialValue: T
): [T, (value: T | ((val: T) => T)) => void, () => void] {
// State to store our value
const [storedValue, setStoredValue] = useState<T>(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];
}

View File

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

View File

@ -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<string, unknown>> = Record<
string,
string | undefined
>;
export type FormTouched<_TValues extends Record<string, unknown>> = Record<
string,
boolean | undefined
>;
export interface ZodFormOptions<TValues extends Record<string, unknown>> {
schema: ZodType<TValues>;
initialValues: TValues;
onSubmit?: (data: TValues) => Promise<void> | void;
}
export interface UseZodFormReturn<TValues extends Record<string, unknown>> {
values: TValues;
errors: FormErrors<TValues>;
touched: FormTouched<TValues>;
submitError: string | null;
isSubmitting: boolean;
isValid: boolean;
setValue: <K extends keyof TValues>(field: K, value: TValues[K]) => void;
setTouched: <K extends keyof TValues>(field: K, touched: boolean) => void;
setTouchedField: <K extends keyof TValues>(field: K, touched?: boolean) => void;
validate: () => boolean;
validateField: <K extends keyof TValues>(field: K) => boolean;
handleSubmit: (event?: FormEvent) => Promise<void>;
reset: () => void;
}
function issuesToErrors<TValues extends Record<string, unknown>>(
issues: ZodIssue[]
): FormErrors<TValues> {
const nextErrors: FormErrors<TValues> = {};
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<TValues extends Record<string, unknown>>({
schema,
initialValues,
onSubmit,
}: ZodFormOptions<TValues>): UseZodFormReturn<TValues> {
const [values, setValues] = useState<TValues>(initialValues);
const [errors, setErrors] = useState<FormErrors<TValues>>({});
const [touched, setTouchedState] = useState<FormTouched<TValues>>({});
const [submitError, setSubmitError] = useState<string | null>(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<TValues> = { ...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<TValues>(error.issues));
}
return false;
}
}, [schema, values]);
const validateField = useCallback(
<K extends keyof TValues>(field: K): boolean => {
const result = schema.safeParse(values);
if (result.success) {
clearFieldError(field);
setErrors(prev => {
if (prev._form === undefined) {
return prev;
}
const next: FormErrors<TValues> = { ...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<TValues> = { ...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(
<K extends keyof TValues>(field: K, value: TValues[K]): void => {
setValues(prev => ({ ...prev, [field]: value }));
clearFieldError(field);
},
[clearFieldError]
);
const setTouched = useCallback(<K extends keyof TValues>(field: K, value: boolean): void => {
setTouchedState(prev => ({ ...prev, [String(field)]: value }));
}, []);
const setTouchedField = useCallback(
<K extends keyof TValues>(field: K, value: boolean = true): void => {
setTouched(field, value);
void validateField(field);
},
[setTouched, validateField]
);
const handleSubmit = useCallback(
async (event?: FormEvent): Promise<void> => {
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<TValues> = { ...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,
]
);
}

View File

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

View File

@ -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<string, unknown> | 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 (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === "development" && <ReactQueryDevtools />}
</QueryClientProvider>
);
}

View File

@ -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<Currency> {
const response = await apiClient.GET<Currency>("/api/currency/default");
return getDataOrThrow(response, "Failed to get default currency");
},
async getAllCurrencies(): Promise<Currency[]> {
const response = await apiClient.GET<Currency[]>("/api/currency/all");
return getDataOrThrow(response, "Failed to get currencies");
},
};

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@
import { z } from "zod"; import { z } from "zod";
import { INVOICE_PAGINATION } from "./constants.js"; import { INVOICE_PAGINATION } from "./constants.js";
import { INVOICE_STATUS } from "./contract.js";
// ============================================================================ // ============================================================================
// Currency (Domain Model) // Currency (Domain Model)
@ -27,17 +28,9 @@ export const currencySchema = z.object({
export type Currency = z.infer<typeof currencySchema>; export type Currency = z.infer<typeof currencySchema>;
// Invoice Status Schema // Invoice Status Schema - derived from contract constants
export const invoiceStatusSchema = z.enum([ const INVOICE_STATUS_VALUES = Object.values(INVOICE_STATUS) as [string, ...string[]];
"Draft", export const invoiceStatusSchema = z.enum(INVOICE_STATUS_VALUES);
"Pending",
"Paid",
"Unpaid",
"Overdue",
"Cancelled",
"Refunded",
"Collections",
]);
// Invoice Item Schema // Invoice Item Schema
export const invoiceItemSchema = z.object({ export const invoiceItemSchema = z.object({

View File

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

View File

@ -4,4 +4,21 @@
* Types and schemas for unified checkout flow. * 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";

View File

@ -6,40 +6,19 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { CHECKOUT_ORDER_TYPE } from "./contract.js";
// ============================================================================ // ============================================================================
// Order Type Schema // 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 * Checkout order types - uses PascalCase to match Salesforce/BFF contracts
* @see packages/domain/orders/contract.ts ORDER_TYPE for canonical values * @see packages/domain/orders/contract.ts ORDER_TYPE for canonical values
*/ */
export const checkoutOrderTypeSchema = z.enum(["Internet", "SIM", "VPN"]); export const checkoutOrderTypeSchema = z.enum(CHECKOUT_ORDER_TYPE_VALUES);
// ============================================================================
// Order Type Helpers
// ============================================================================
/**
* Convert legacy uppercase order type to PascalCase
* Used for migrating old localStorage data
*/
export function normalizeOrderType(value: unknown): z.infer<typeof checkoutOrderTypeSchema> | 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;
}
}
// ============================================================================ // ============================================================================
// Price Breakdown Schema // Price Breakdown Schema

View File

@ -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<NotificationTypeValue, NotificationTemplate> = {
[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>
): 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";

View File

@ -5,26 +5,27 @@
* Used for in-app notifications synced with Salesforce email triggers. * Used for in-app notifications synced with Salesforce email triggers.
*/ */
// Contracts (enums, constants, templates)
export { export {
// Enums
NOTIFICATION_TYPE, NOTIFICATION_TYPE,
NOTIFICATION_SOURCE, NOTIFICATION_SOURCE,
type NotificationTypeValue,
type NotificationSourceValue,
// Templates
NOTIFICATION_TEMPLATES, NOTIFICATION_TEMPLATES,
getNotificationTemplate, getNotificationTemplate,
// Schemas type NotificationTypeValue,
type NotificationSourceValue,
type NotificationTemplate,
} from "./contract.js";
// Schemas and schema-derived types
export {
notificationSchema, notificationSchema,
createNotificationRequestSchema, createNotificationRequestSchema,
notificationListResponseSchema, notificationListResponseSchema,
notificationUnreadCountResponseSchema, notificationUnreadCountResponseSchema,
notificationQuerySchema, notificationQuerySchema,
notificationIdParamSchema, notificationIdParamSchema,
// Types
type Notification, type Notification,
type CreateNotificationRequest, type CreateNotificationRequest,
type NotificationTemplate,
type NotificationListResponse, type NotificationListResponse,
type NotificationUnreadCountResponse, type NotificationUnreadCountResponse,
type NotificationQuery, type NotificationQuery,

View File

@ -5,164 +5,7 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { NOTIFICATION_TYPE, NOTIFICATION_SOURCE } from "./contract.js";
// =============================================================================
// 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<NotificationTypeValue, NotificationTemplate> = {
[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>
): NotificationTemplate {
const template = NOTIFICATION_TEMPLATES[type];
if (!template) {
throw new Error(`Unknown notification type: ${type}`);
}
return { ...template, ...overrides };
}
// ============================================================================= // =============================================================================
// Schemas // Schemas