From bde9f706ced8b44e869b8ec5d6dc36ec98cd9de7 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 13 Jan 2026 16:19:39 +0900 Subject: [PATCH] feat: add VPN services and call history management features - Implemented VpnServicesService for managing VPN plans and activation fees. - Created SimCallHistoryFormatterService for formatting call history data. - Developed SimCallHistoryParserService to parse call history CSV files. - Added AnimatedContainer and AnimatedBackground components for UI animations. - Introduced BentoServiceCard, FloatingGlassCard, GlowButton, and ValuePropCard components for landing page. - Implemented useCountUp hook for animated number counting. - Added cancellation months utility functions for subscription management. --- apps/bff/src/core/errors/index.ts | 24 + .../src/core/errors/user-friendly-messages.ts | 232 ++++++++ apps/bff/src/core/utils/array.util.ts | 67 +++ apps/bff/src/core/utils/index.ts | 30 ++ .../integrations/freebit/freebit.module.ts | 12 +- .../services/freebit-error-handler.service.ts | 195 +++++++ .../services/freebit-operations.service.ts | 50 +- .../services/freebit-orchestrator.service.ts | 166 ------ .../integrations/freebit/services/index.ts | 1 - .../events/account-events.subscriber.ts | 2 +- .../events/catalog-cdc.subscriber.ts | 2 +- .../salesforce/salesforce.module.ts | 3 + .../salesforce-error-handler.service.ts | 226 ++++++++ .../whmcs/services/whmcs-invoice.service.ts | 62 +-- .../modules/me-status/me-status.service.ts | 2 +- .../orders/services/checkout.service.ts | 8 +- .../services/order-validator.service.ts | 4 +- .../services/sim-fulfillment.service.ts | 4 +- .../services/account-services.controller.ts | 6 +- .../base-services.service.ts | 0 .../internet-eligibility.service.ts | 0 .../internet-eligibility.types.ts | 0 .../internet-services.service.ts | 0 .../services-cache.service.ts | 0 .../sim-services.service.ts | 0 .../vpn-services.service.ts | 0 .../internet-eligibility.controller.ts | 2 +- .../services/public-services.controller.ts | 6 +- .../services/services-health.controller.ts | 4 +- .../modules/services/services.controller.ts | 6 +- .../src/modules/services/services.module.ts | 12 +- .../services/internet-cancellation.service.ts | 56 +- .../queue/sim-management.processor.ts | 4 +- .../services/esim-management.service.ts | 18 +- .../services/sim-action-runner.service.ts | 35 -- .../services/sim-api-notification.service.ts | 168 ------ .../sim-call-history-formatter.service.ts | 84 +++ .../sim-call-history-parser.service.ts | 295 ++++++++++ .../services/sim-call-history.service.ts | 405 ++------------ .../services/sim-cancellation.service.ts | 83 +-- .../services/sim-details.service.ts | 4 +- .../services/sim-notification.service.ts | 252 ++++++++- .../services/sim-plan.service.ts | 18 +- .../services/sim-topup.service.ts | 14 +- .../services/sim-usage.service.ts | 4 +- .../sim-management/sim-management.module.ts | 11 +- .../sim-order-activation.service.ts | 4 +- .../verification/residence-card.service.ts | 2 +- apps/portal/package.json | 1 + apps/portal/src/app/globals.css | 143 +++-- apps/portal/src/app/layout.tsx | 12 +- .../components/atoms/AnimatedContainer.tsx | 42 ++ apps/portal/src/components/atoms/button.tsx | 2 +- apps/portal/src/components/atoms/index.ts | 3 + .../src/components/atoms/inline-toast.tsx | 28 +- apps/portal/src/components/atoms/input.tsx | 7 +- .../src/components/atoms/loading-skeleton.tsx | 4 +- .../dashboard/components/ActivityFeed.tsx | 16 +- .../dashboard/components/QuickStats.tsx | 17 +- .../dashboard/views/DashboardView.tsx | 57 +- .../components/AnimatedBackground.tsx | 70 +++ .../components/BentoServiceCard.tsx | 192 +++++++ .../components/FloatingGlassCard.tsx | 61 +++ .../landing-page/components/GlowButton.tsx | 62 +++ .../landing-page/components/ValuePropCard.tsx | 65 +++ .../features/landing-page/components/index.ts | 5 + .../landing-page/views/PublicLandingView.tsx | 423 ++++++++------- apps/portal/src/shared/hooks/index.ts | 1 + apps/portal/src/shared/hooks/useCountUp.ts | 70 +++ apps/portal/src/styles/tokens.css | 36 ++ apps/portal/src/styles/utilities.css | 503 +++++++++++++++++- packages/domain/subscriptions/index.ts | 10 + .../utils/cancellation-months.ts | 155 ++++++ packages/domain/subscriptions/utils/index.ts | 14 + pnpm-lock.yaml | 15 + 75 files changed, 3341 insertions(+), 1256 deletions(-) create mode 100644 apps/bff/src/core/errors/index.ts create mode 100644 apps/bff/src/core/errors/user-friendly-messages.ts create mode 100644 apps/bff/src/core/utils/array.util.ts create mode 100644 apps/bff/src/core/utils/index.ts create mode 100644 apps/bff/src/integrations/freebit/services/freebit-error-handler.service.ts delete mode 100644 apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts create mode 100644 apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts rename apps/bff/src/modules/services/{services => application}/base-services.service.ts (100%) rename apps/bff/src/modules/services/{services => application}/internet-eligibility.service.ts (100%) rename apps/bff/src/modules/services/{services => application}/internet-eligibility.types.ts (100%) rename apps/bff/src/modules/services/{services => application}/internet-services.service.ts (100%) rename apps/bff/src/modules/services/{services => application}/services-cache.service.ts (100%) rename apps/bff/src/modules/services/{services => application}/sim-services.service.ts (100%) rename apps/bff/src/modules/services/{services => application}/vpn-services.service.ts (100%) delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-action-runner.service.ts delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-api-notification.service.ts create mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts create mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts create mode 100644 apps/portal/src/components/atoms/AnimatedContainer.tsx create mode 100644 apps/portal/src/features/landing-page/components/AnimatedBackground.tsx create mode 100644 apps/portal/src/features/landing-page/components/BentoServiceCard.tsx create mode 100644 apps/portal/src/features/landing-page/components/FloatingGlassCard.tsx create mode 100644 apps/portal/src/features/landing-page/components/GlowButton.tsx create mode 100644 apps/portal/src/features/landing-page/components/ValuePropCard.tsx create mode 100644 apps/portal/src/features/landing-page/components/index.ts create mode 100644 apps/portal/src/shared/hooks/useCountUp.ts create mode 100644 packages/domain/subscriptions/utils/cancellation-months.ts create mode 100644 packages/domain/subscriptions/utils/index.ts diff --git a/apps/bff/src/core/errors/index.ts b/apps/bff/src/core/errors/index.ts new file mode 100644 index 00000000..509f0271 --- /dev/null +++ b/apps/bff/src/core/errors/index.ts @@ -0,0 +1,24 @@ +/** + * Core Error Utilities + * + * Shared error handling utilities for the BFF. + */ + +// User-friendly message utilities +export { + matchCommonError, + getUserFriendlyMessage, + isTimeoutError, + isNetworkError, + isAuthError, + isRateLimitError, + isPermissionError, + getTimeoutMessage, + getNetworkMessage, + getAuthMessage, + getRateLimitMessage, + getPermissionMessage, + getDefaultMessage, + type ErrorMatchResult, + type ErrorCategory, +} from "./user-friendly-messages.js"; diff --git a/apps/bff/src/core/errors/user-friendly-messages.ts b/apps/bff/src/core/errors/user-friendly-messages.ts new file mode 100644 index 00000000..829de841 --- /dev/null +++ b/apps/bff/src/core/errors/user-friendly-messages.ts @@ -0,0 +1,232 @@ +/** + * User-Friendly Error Messages + * + * Common error pattern matchers and message generators that can be composed + * with domain-specific error handlers. + * + * Usage: + * - Use `matchCommonError()` for generic network/timeout/auth errors + * - Use domain-specific handlers for business logic errors + * - Compose both for complete error handling + */ + +/** + * Common error pattern result + */ +export interface ErrorMatchResult { + matched: boolean; + message: string; +} + +/** + * Error pattern category for context-aware messages + */ +export type ErrorCategory = "generic" | "sim" | "payment" | "billing" | "support"; + +/** + * Check if error message matches a timeout pattern + */ +export function isTimeoutError(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return lowerMessage.includes("timeout") || lowerMessage.includes("timed out"); +} + +/** + * Check if error message matches a network/connection pattern + */ +export function isNetworkError(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return ( + lowerMessage.includes("network") || + lowerMessage.includes("connection") || + lowerMessage.includes("econnrefused") || + lowerMessage.includes("econnreset") || + lowerMessage.includes("socket hang up") || + lowerMessage.includes("fetch failed") + ); +} + +/** + * Check if error message matches an authentication pattern + */ +export function isAuthError(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return ( + lowerMessage.includes("unauthorized") || + lowerMessage.includes("forbidden") || + lowerMessage.includes("authentication failed") || + lowerMessage.includes("403") || + lowerMessage.includes("401") + ); +} + +/** + * Check if error message matches a rate limit pattern + */ +export function isRateLimitError(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return ( + lowerMessage.includes("rate limit") || + lowerMessage.includes("too many requests") || + lowerMessage.includes("429") + ); +} + +/** + * Check if error message matches a permission error + */ +export function isPermissionError(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return lowerMessage.includes("invalid permissions") || lowerMessage.includes("not allowed"); +} + +/** + * Get context-aware message for timeout errors + */ +export function getTimeoutMessage(category: ErrorCategory = "generic"): string { + switch (category) { + case "sim": + return "SIM service request timed out. Please try again."; + case "payment": + return "Payment processing timed out. Please try again."; + default: + return "The request timed out. Please try again."; + } +} + +/** + * Get context-aware message for network errors + */ +export function getNetworkMessage(category: ErrorCategory = "generic"): string { + switch (category) { + case "sim": + return "SIM service request timed out. Please try again."; + case "payment": + return "Payment processing timed out. Please try again."; + default: + return "Network error. Please check your connection and try again."; + } +} + +/** + * Get context-aware message for auth errors + */ +export function getAuthMessage(category: ErrorCategory = "generic"): string { + switch (category) { + case "sim": + return "SIM service is temporarily unavailable. Please try again later."; + case "payment": + return "Payment processing is temporarily unavailable. Please contact support for assistance."; + default: + return "Service is temporarily unavailable. Please try again later."; + } +} + +/** + * Get context-aware message for rate limit errors + */ +export function getRateLimitMessage(category: ErrorCategory = "generic"): string { + switch (category) { + case "sim": + return "SIM service is busy. Please wait a moment and try again."; + case "payment": + return "Payment service is busy. Please wait a moment and try again."; + default: + return "Service is busy. Please wait a moment and try again."; + } +} + +/** + * Get context-aware message for permission errors + */ +export function getPermissionMessage(category: ErrorCategory = "generic"): string { + switch (category) { + case "sim": + return "SIM service is temporarily unavailable. Please contact support for assistance."; + case "payment": + return "Payment processing is temporarily unavailable. Please contact support for assistance."; + default: + return "This operation is temporarily unavailable. Please contact support for assistance."; + } +} + +/** + * Get default fallback message for category + */ +export function getDefaultMessage(category: ErrorCategory = "generic"): string { + switch (category) { + case "sim": + return "SIM operation failed. Please try again or contact support."; + case "payment": + return "Unable to process payment. Please try again or contact support."; + case "billing": + return "Billing operation failed. Please try again or contact support."; + case "support": + return "Unable to complete request. Please try again or contact support."; + default: + return "An unexpected error occurred. Please try again later."; + } +} + +/** + * Match common error patterns and return appropriate user-friendly message. + * + * @param errorMessage - The technical error message to match + * @param category - Context category for appropriate messaging + * @returns Match result with whether pattern matched and the message + * + * @example + * const result = matchCommonError(error.message, "sim"); + * if (result.matched) { + * return result.message; + * } + * // Fall through to domain-specific error handling + */ +export function matchCommonError( + errorMessage: string, + category: ErrorCategory = "generic" +): ErrorMatchResult { + if (!errorMessage) { + return { matched: false, message: getDefaultMessage(category) }; + } + + // Check patterns in order of specificity + if (isRateLimitError(errorMessage)) { + return { matched: true, message: getRateLimitMessage(category) }; + } + + if (isAuthError(errorMessage)) { + return { matched: true, message: getAuthMessage(category) }; + } + + if (isPermissionError(errorMessage)) { + return { matched: true, message: getPermissionMessage(category) }; + } + + if (isTimeoutError(errorMessage)) { + return { matched: true, message: getTimeoutMessage(category) }; + } + + if (isNetworkError(errorMessage)) { + return { matched: true, message: getNetworkMessage(category) }; + } + + return { matched: false, message: getDefaultMessage(category) }; +} + +/** + * Get user-friendly message for any error, with fallback to default. + * + * This is a convenience function that always returns a user-friendly message. + * Use this when you don't need to check if a common pattern matched. + * + * @example + * return getUserFriendlyMessage(error.message, "payment"); + */ +export function getUserFriendlyMessage( + errorMessage: string, + category: ErrorCategory = "generic" +): string { + const result = matchCommonError(errorMessage, category); + return result.message; +} diff --git a/apps/bff/src/core/utils/array.util.ts b/apps/bff/src/core/utils/array.util.ts new file mode 100644 index 00000000..22953d59 --- /dev/null +++ b/apps/bff/src/core/utils/array.util.ts @@ -0,0 +1,67 @@ +/** + * Array Utilities + * + * Common array manipulation functions used across the BFF. + */ + +/** + * Split an array into chunks of specified size. + * + * @example + * chunkArray([1, 2, 3, 4, 5], 2) // [[1, 2], [3, 4], [5]] + * chunkArray(['a', 'b', 'c'], 3) // [['a', 'b', 'c']] + */ +export function chunkArray(array: T[], size: number): T[][] { + if (size <= 0) { + throw new Error("Chunk size must be greater than 0"); + } + + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; +} + +/** + * Group array items by a key function. + * + * @example + * groupBy([{type: 'a', val: 1}, {type: 'b', val: 2}, {type: 'a', val: 3}], x => x.type) + * // { a: [{type: 'a', val: 1}, {type: 'a', val: 3}], b: [{type: 'b', val: 2}] } + */ +export function groupBy( + array: T[], + keyFn: (item: T) => K +): Record { + return array.reduce( + (result, item) => { + const key = keyFn(item); + if (!result[key]) { + result[key] = []; + } + result[key].push(item); + return result; + }, + {} as Record + ); +} + +/** + * Remove duplicate items from an array based on a key function. + * + * @example + * uniqueBy([{id: 1, name: 'a'}, {id: 1, name: 'b'}, {id: 2, name: 'c'}], x => x.id) + * // [{id: 1, name: 'a'}, {id: 2, name: 'c'}] + */ +export function uniqueBy(array: T[], keyFn: (item: T) => K): T[] { + const seen = new Set(); + return array.filter(item => { + const key = keyFn(item); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} diff --git a/apps/bff/src/core/utils/index.ts b/apps/bff/src/core/utils/index.ts new file mode 100644 index 00000000..80a7c974 --- /dev/null +++ b/apps/bff/src/core/utils/index.ts @@ -0,0 +1,30 @@ +/** + * Core Utilities + * + * Shared utility functions used across the BFF. + */ + +// Array utilities +export { chunkArray, groupBy, uniqueBy } from "./array.util.js"; + +// Error utilities +export { extractErrorMessage } from "./error.util.js"; +export { withErrorHandling, withErrorSuppression, withErrorLogging } from "./error-handler.util.js"; + +// Retry utilities +export { + withRetry, + withRetrySafe, + sleep, + calculateBackoffDelay, + getRateLimitDelay, + RetryableErrors, + type RetryOptions, + type RetryResult, +} from "./retry.util.js"; + +// Validation utilities +export { parseUuidOrThrow } from "./validation.util.js"; + +// SSO utilities +export { sanitizeWhmcsRedirectPath } from "./sso.util.js"; diff --git a/apps/bff/src/integrations/freebit/freebit.module.ts b/apps/bff/src/integrations/freebit/freebit.module.ts index 9d0f6488..00ae6e2e 100644 --- a/apps/bff/src/integrations/freebit/freebit.module.ts +++ b/apps/bff/src/integrations/freebit/freebit.module.ts @@ -1,5 +1,4 @@ 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"; import { FreebitClientService } from "./services/freebit-client.service.js"; @@ -11,12 +10,14 @@ 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 { FreebitErrorHandlerService } from "./services/freebit-error-handler.service.js"; import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.module.js"; @Module({ imports: [VoiceOptionsModule], providers: [ // Core services + FreebitErrorHandlerService, FreebitClientService, FreebitAuthService, FreebitMapperService, @@ -28,14 +29,13 @@ import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.mo FreebitVoiceService, FreebitCancellationService, FreebitEsimService, - // Facade (delegates to specialized services) + // Facade (delegates to specialized services, handles account normalization) FreebitOperationsService, - FreebitOrchestratorService, ], exports: [ - // Export orchestrator for high-level operations - FreebitOrchestratorService, - // Export facade for backward compatibility + // Export error handler + FreebitErrorHandlerService, + // Export main facade for all Freebit operations FreebitOperationsService, // Export specialized services for direct access if needed FreebitAccountService, diff --git a/apps/bff/src/integrations/freebit/services/freebit-error-handler.service.ts b/apps/bff/src/integrations/freebit/services/freebit-error-handler.service.ts new file mode 100644 index 00000000..bea58fa5 --- /dev/null +++ b/apps/bff/src/integrations/freebit/services/freebit-error-handler.service.ts @@ -0,0 +1,195 @@ +import { Injectable, HttpStatus } from "@nestjs/common"; +import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common"; +import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { matchCommonError } from "@bff/core/errors/index.js"; +import { FreebitError } from "./freebit-error.service.js"; + +/** + * Service for handling and normalizing Freebit API errors. + * Maps Freebit errors to appropriate NestJS exceptions. + * + * Mirrors the pattern used by WhmcsErrorHandlerService and SalesforceErrorHandlerService. + */ +@Injectable() +export class FreebitErrorHandlerService { + /** + * Handle Freebit API error (from FreebitError or raw error) + */ + handleApiError(error: unknown, context: string): never { + if (error instanceof FreebitError) { + const mapped = this.mapFreebitErrorToDomain(error); + throw new DomainHttpException(mapped.code, mapped.status, error.message); + } + + // Handle generic errors + this.handleRequestError(error, context); + } + + /** + * Handle general request errors (network, timeout, etc.) + */ + handleRequestError(error: unknown, context: string): never { + const message = extractErrorMessage(error); + + // Check for timeout + if (this.isTimeoutError(message)) { + throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); + } + + // Check for network errors + if (this.isNetworkError(message)) { + throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY); + } + + // Check for rate limiting + if (this.isRateLimitError(message)) { + throw new DomainHttpException( + ErrorCode.RATE_LIMITED, + HttpStatus.TOO_MANY_REQUESTS, + "Freebit rate limit exceeded" + ); + } + + // Check for auth errors + if (this.isAuthError(message)) { + throw new DomainHttpException( + ErrorCode.EXTERNAL_SERVICE_ERROR, + HttpStatus.SERVICE_UNAVAILABLE, + "Freebit authentication failed" + ); + } + + // Re-throw if already a DomainHttpException + if (error instanceof DomainHttpException) { + throw error; + } + + // Wrap unknown errors + throw new DomainHttpException( + ErrorCode.EXTERNAL_SERVICE_ERROR, + HttpStatus.BAD_GATEWAY, + `Freebit ${context} failed` + ); + } + + /** + * Map FreebitError to domain error codes + */ + private mapFreebitErrorToDomain(error: FreebitError): { + code: ErrorCodeType; + status: HttpStatus; + } { + const resultCode = String(error.resultCode || ""); + const statusCode = String(error.statusCode || ""); + const message = error.message.toLowerCase(); + + // Authentication errors + if (error.isAuthError()) { + return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE }; + } + + // Rate limit errors + if (error.isRateLimitError()) { + return { code: ErrorCode.RATE_LIMITED, status: HttpStatus.TOO_MANY_REQUESTS }; + } + + // Not found errors + if (message.includes("account not found") || message.includes("no such account")) { + return { code: ErrorCode.NOT_FOUND, status: HttpStatus.NOT_FOUND }; + } + + // Plan change specific errors + if (resultCode === "215" || statusCode === "215") { + return { code: ErrorCode.VALIDATION_FAILED, status: HttpStatus.BAD_REQUEST }; + } + + // Network type change errors + if ( + resultCode === "381" || + statusCode === "381" || + resultCode === "382" || + statusCode === "382" + ) { + return { code: ErrorCode.VALIDATION_FAILED, status: HttpStatus.BAD_REQUEST }; + } + + // Server errors (retryable) + if (error.isRetryable() && !this.isTimeoutError(error.message)) { + return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY }; + } + + // Timeout + if (this.isTimeoutError(error.message)) { + return { code: ErrorCode.TIMEOUT, status: HttpStatus.GATEWAY_TIMEOUT }; + } + + // Default: external service error + return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY }; + } + + /** + * Check if message indicates timeout + */ + private isTimeoutError(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return lowerMessage.includes("timeout") || lowerMessage.includes("aborted"); + } + + /** + * Check if message indicates network error + */ + private isNetworkError(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return ( + lowerMessage.includes("network") || + lowerMessage.includes("connection") || + lowerMessage.includes("econnrefused") || + lowerMessage.includes("fetch failed") + ); + } + + /** + * Check if message indicates rate limit + */ + private isRateLimitError(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return ( + lowerMessage.includes("rate limit") || + lowerMessage.includes("too many requests") || + lowerMessage.includes("429") + ); + } + + /** + * Check if message indicates auth error + */ + private isAuthError(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return ( + lowerMessage.includes("authentication") || + lowerMessage.includes("unauthorized") || + lowerMessage.includes("401") + ); + } + + /** + * Get user-friendly error message for Freebit errors + */ + getUserFriendlyMessage(error: unknown): string { + // Use FreebitError's built-in method if available + if (error instanceof FreebitError) { + return error.getUserFriendlyMessage(); + } + + // Fall back to common error patterns + const message = extractErrorMessage(error); + const commonResult = matchCommonError(message, "sim"); + + if (commonResult.matched) { + return commonResult.message; + } + + return "SIM operation failed. Please try again or contact support."; + } +} diff --git a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts index 7f01beaf..ffdcc19d 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts @@ -5,6 +5,7 @@ import { FreebitPlanService } from "./freebit-plan.service.js"; import { FreebitVoiceService } from "./freebit-voice.service.js"; import { FreebitCancellationService } from "./freebit-cancellation.service.js"; import { FreebitEsimService, type EsimActivationParams } from "./freebit-esim.service.js"; +import { FreebitMapperService } from "./freebit-mapper.service.js"; import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types.js"; /** @@ -13,6 +14,9 @@ import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebi * Unified interface for all Freebit SIM operations. * Delegates to specialized services for each operation type. * + * This service handles account normalization automatically, so consumers + * don't need to worry about formatting phone numbers correctly. + * * Services: * - FreebitAccountService: SIM details, health checks * - FreebitUsageService: Usage queries, top-ups, quota history @@ -29,9 +33,17 @@ export class FreebitOperationsService { private readonly planService: FreebitPlanService, private readonly voiceService: FreebitVoiceService, private readonly cancellationService: FreebitCancellationService, - private readonly esimService: FreebitEsimService + private readonly esimService: FreebitEsimService, + private readonly mapper: FreebitMapperService ) {} + /** + * Normalize account identifier (remove formatting like dashes, spaces) + */ + private normalizeAccount(account: string): string { + return this.mapper.normalizeAccount(account); + } + // ============================================================================ // Account Operations (delegated to FreebitAccountService) // ============================================================================ @@ -40,7 +52,8 @@ export class FreebitOperationsService { * Get SIM account details with endpoint fallback */ async getSimDetails(account: string): Promise { - return this.accountService.getSimDetails(account); + const normalizedAccount = this.normalizeAccount(account); + return this.accountService.getSimDetails(normalizedAccount); } /** @@ -58,7 +71,8 @@ export class FreebitOperationsService { * Get SIM usage/traffic information */ async getSimUsage(account: string): Promise { - return this.usageService.getSimUsage(account); + const normalizedAccount = this.normalizeAccount(account); + return this.usageService.getSimUsage(normalizedAccount); } /** @@ -69,7 +83,8 @@ export class FreebitOperationsService { quotaMb: number, options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {} ): Promise { - return this.usageService.topUpSim(account, quotaMb, options); + const normalizedAccount = this.normalizeAccount(account); + return this.usageService.topUpSim(normalizedAccount, quotaMb, options); } /** @@ -80,7 +95,8 @@ export class FreebitOperationsService { fromDate: string, toDate: string ): Promise { - return this.usageService.getSimTopUpHistory(account, fromDate, toDate); + const normalizedAccount = this.normalizeAccount(account); + return this.usageService.getSimTopUpHistory(normalizedAccount, fromDate, toDate); } // ============================================================================ @@ -96,7 +112,8 @@ export class FreebitOperationsService { newPlanCode: string, options: { assignGlobalIp?: boolean; scheduledAt?: string } = {} ): Promise<{ ipv4?: string; ipv6?: string }> { - return this.planService.changeSimPlan(account, newPlanCode, options); + const normalizedAccount = this.normalizeAccount(account); + return this.planService.changeSimPlan(normalizedAccount, newPlanCode, options); } // ============================================================================ @@ -115,7 +132,8 @@ export class FreebitOperationsService { networkType?: "4G" | "5G"; } ): Promise { - return this.voiceService.updateSimFeatures(account, features); + const normalizedAccount = this.normalizeAccount(account); + return this.voiceService.updateSimFeatures(normalizedAccount, features); } // ============================================================================ @@ -126,14 +144,16 @@ export class FreebitOperationsService { * Cancel SIM plan (PA05-04 - plan cancellation only) */ async cancelSim(account: string, scheduledAt?: string): Promise { - return this.cancellationService.cancelSim(account, scheduledAt); + const normalizedAccount = this.normalizeAccount(account); + return this.cancellationService.cancelSim(normalizedAccount, scheduledAt); } /** * Cancel SIM account (PA02-04 - full account cancellation) */ async cancelAccount(account: string, runDate?: string): Promise { - return this.cancellationService.cancelAccount(account, runDate); + const normalizedAccount = this.normalizeAccount(account); + return this.cancellationService.cancelAccount(normalizedAccount, runDate); } // ============================================================================ @@ -144,7 +164,8 @@ export class FreebitOperationsService { * Reissue eSIM profile (simple version) */ async reissueEsimProfile(account: string): Promise { - return this.esimService.reissueEsimProfile(account); + const normalizedAccount = this.normalizeAccount(account); + return this.esimService.reissueEsimProfile(normalizedAccount); } /** @@ -155,13 +176,18 @@ export class FreebitOperationsService { newEid: string, options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {} ): Promise { - return this.esimService.reissueEsimProfileEnhanced(account, newEid, options); + const normalizedAccount = this.normalizeAccount(account); + return this.esimService.reissueEsimProfileEnhanced(normalizedAccount, newEid, options); } /** * Activate new eSIM account using PA05-41 (addAcct) */ async activateEsimAccountNew(params: EsimActivationParams): Promise { - return this.esimService.activateEsimAccountNew(params); + const normalizedParams = { + ...params, + account: this.normalizeAccount(params.account), + }; + return this.esimService.activateEsimAccountNew(normalizedParams); } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts b/apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts deleted file mode 100644 index f4c1db48..00000000 --- a/apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { FreebitOperationsService } from "./freebit-operations.service.js"; -import { FreebitMapperService } from "./freebit-mapper.service.js"; -import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types.js"; - -@Injectable() -export class FreebitOrchestratorService { - constructor( - private readonly operations: FreebitOperationsService, - private readonly mapper: FreebitMapperService - ) {} - - /** - * Get SIM account details - */ - async getSimDetails(account: string): Promise { - const normalizedAccount = this.mapper.normalizeAccount(account); - return this.operations.getSimDetails(normalizedAccount); - } - - /** - * Get SIM usage information - */ - async getSimUsage(account: string): Promise { - const normalizedAccount = this.mapper.normalizeAccount(account); - return this.operations.getSimUsage(normalizedAccount); - } - - /** - * Top up SIM data quota - */ - async topUpSim( - account: string, - quotaMb: number, - options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {} - ): Promise { - const normalizedAccount = this.mapper.normalizeAccount(account); - return this.operations.topUpSim(normalizedAccount, quotaMb, options); - } - - /** - * Get SIM top-up history - */ - async getSimTopUpHistory( - account: string, - fromDate: string, - toDate: string - ): Promise { - const normalizedAccount = this.mapper.normalizeAccount(account); - return this.operations.getSimTopUpHistory(normalizedAccount, fromDate, toDate); - } - - /** - * Change SIM plan - */ - async changeSimPlan( - account: string, - newPlanCode: string, - options: { assignGlobalIp?: boolean; scheduledAt?: string } = {} - ): Promise<{ ipv4?: string; ipv6?: string }> { - const normalizedAccount = this.mapper.normalizeAccount(account); - return this.operations.changeSimPlan(normalizedAccount, newPlanCode, options); - } - - /** - * Update SIM features - */ - async updateSimFeatures( - account: string, - features: { - voiceMailEnabled?: boolean; - callWaitingEnabled?: boolean; - internationalRoamingEnabled?: boolean; - networkType?: "4G" | "5G"; - } - ): Promise { - const normalizedAccount = this.mapper.normalizeAccount(account); - return this.operations.updateSimFeatures(normalizedAccount, features); - } - - /** - * Cancel SIM service (plan cancellation - PA05-04) - */ - async cancelSim(account: string, scheduledAt?: string): Promise { - const normalizedAccount = this.mapper.normalizeAccount(account); - return this.operations.cancelSim(normalizedAccount, scheduledAt); - } - - /** - * Cancel SIM account (full account cancellation - PA02-04) - */ - async cancelAccount(account: string, runDate?: string): Promise { - const normalizedAccount = this.mapper.normalizeAccount(account); - return this.operations.cancelAccount(normalizedAccount, runDate); - } - - /** - * Reissue eSIM profile (simple) - */ - async reissueEsimProfile(account: string): Promise { - const normalizedAccount = this.mapper.normalizeAccount(account); - return this.operations.reissueEsimProfile(normalizedAccount); - } - - /** - * Reissue eSIM profile with enhanced options - */ - async reissueEsimProfileEnhanced( - account: string, - newEid: string, - options: { oldEid?: string; planCode?: string } = {} - ): Promise { - const normalizedAccount = this.mapper.normalizeAccount(account); - return this.operations.reissueEsimProfileEnhanced(normalizedAccount, newEid, options); - } - - /** - * Activate new eSIM account - */ - async activateEsimAccountNew(params: { - account: string; - eid: string; - planCode?: string; - contractLine?: "4G" | "5G"; - aladinOperated?: "10" | "20"; - shipDate?: string; - addKind?: "N" | "M" | "R"; - simKind?: "E0" | "E2" | "E3"; // E0:音声あり, E2:SMSなし, E3:SMSあり - repAccount?: string; - deliveryCode?: string; - globalIp?: "10" | "20"; - mnp?: { reserveNumber: string; reserveExpireDate?: string }; - identity?: { - firstnameKanji?: string; - lastnameKanji?: string; - firstnameZenKana?: string; - lastnameZenKana?: string; - gender?: string; - birthday?: string; - }; - }): Promise { - const normalizedAccount = this.mapper.normalizeAccount(params.account); - return this.operations.activateEsimAccountNew({ - account: normalizedAccount, - eid: params.eid, - planCode: params.planCode, - contractLine: params.contractLine, - aladinOperated: params.aladinOperated, - shipDate: params.shipDate, - addKind: params.addKind, - simKind: params.simKind, - repAccount: params.repAccount, - deliveryCode: params.deliveryCode, - globalIp: params.globalIp, - mnp: params.mnp, - identity: params.identity, - }); - } - - /** - * Health check - */ - async healthCheck(): Promise { - return this.operations.healthCheck(); - } -} diff --git a/apps/bff/src/integrations/freebit/services/index.ts b/apps/bff/src/integrations/freebit/services/index.ts index 4f5413d7..da34e24b 100644 --- a/apps/bff/src/integrations/freebit/services/index.ts +++ b/apps/bff/src/integrations/freebit/services/index.ts @@ -1,5 +1,4 @@ // Export all Freebit services -export { FreebitOrchestratorService } from "./freebit-orchestrator.service.js"; export { FreebitMapperService } from "./freebit-mapper.service.js"; export { FreebitOperationsService } from "./freebit-operations.service.js"; export { FreebitRateLimiterService } from "./freebit-rate-limiter.service.js"; diff --git a/apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts b/apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts index 473afadf..4ab760ea 100644 --- a/apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts @@ -23,7 +23,7 @@ import { extractPayload, extractStringField, } from "./shared/index.js"; -import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js"; +import { ServicesCacheService } from "@bff/modules/services/application/services-cache.service.js"; import { RealtimeService } from "@bff/infra/realtime/realtime.service.js"; import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js"; diff --git a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts index ebc16f85..a73b146e 100644 --- a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts @@ -21,7 +21,7 @@ import { extractStringField, extractRecordIds, } from "./shared/index.js"; -import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js"; +import { ServicesCacheService } from "@bff/modules/services/application/services-cache.service.js"; import { RealtimeService } from "@bff/infra/realtime/realtime.service.js"; @Injectable() diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index a70b5936..db2d7b8e 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -14,10 +14,12 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle import { OpportunityQueryService } from "./services/opportunity/opportunity-query.service.js"; import { OpportunityCancellationService } from "./services/opportunity/opportunity-cancellation.service.js"; import { OpportunityMutationService } from "./services/opportunity/opportunity-mutation.service.js"; +import { SalesforceErrorHandlerService } from "./services/salesforce-error-handler.service.js"; @Module({ imports: [QueueModule, ConfigModule, SalesforceOrderFieldConfigModule], providers: [ + SalesforceErrorHandlerService, SalesforceConnection, SalesforceAccountService, SalesforceOrderService, @@ -35,6 +37,7 @@ import { OpportunityMutationService } from "./services/opportunity/opportunity-m ], exports: [ QueueModule, + SalesforceErrorHandlerService, SalesforceService, SalesforceConnection, SalesforceAccountService, diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts new file mode 100644 index 00000000..56e757b2 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/salesforce-error-handler.service.ts @@ -0,0 +1,226 @@ +import { Injectable, HttpStatus } from "@nestjs/common"; +import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common"; +import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { matchCommonError } from "@bff/core/errors/index.js"; + +/** + * Salesforce error response structure + */ +export interface SalesforceErrorResponse { + errorCode?: string; + message?: string; + fields?: string[]; +} + +/** + * Service for handling and normalizing Salesforce API errors. + * Maps Salesforce errors to appropriate NestJS exceptions. + * + * Mirrors the pattern used by WhmcsErrorHandlerService for consistency. + */ +@Injectable() +export class SalesforceErrorHandlerService { + /** + * Handle Salesforce API error response + */ + handleApiError( + errorResponse: SalesforceErrorResponse | SalesforceErrorResponse[], + context: string + ): never { + // Salesforce can return an array of errors + const errors = Array.isArray(errorResponse) ? errorResponse : [errorResponse]; + const firstError = errors[0] || {}; + + const errorCode = firstError.errorCode || "UNKNOWN_ERROR"; + const message = firstError.message || "Salesforce operation failed"; + + const mapped = this.mapSalesforceErrorToDomain(errorCode, message, context); + throw new DomainHttpException(mapped.code, mapped.status, message); + } + + /** + * Handle general request errors (network, timeout, session expired, etc.) + */ + handleRequestError(error: unknown, context: string): never { + // Check for session expired + if (this.isSessionExpiredError(error)) { + throw new DomainHttpException( + ErrorCode.EXTERNAL_SERVICE_ERROR, + HttpStatus.SERVICE_UNAVAILABLE, + "Salesforce session expired" + ); + } + + // Check for timeout + if (this.isTimeoutError(error)) { + throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); + } + + // Check for network errors + if (this.isNetworkError(error)) { + throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY); + } + + // Check for rate limiting + if (this.isRateLimitError(error)) { + throw new DomainHttpException( + ErrorCode.RATE_LIMITED, + HttpStatus.TOO_MANY_REQUESTS, + "Salesforce rate limit exceeded" + ); + } + + // Re-throw if already a DomainHttpException + if (error instanceof DomainHttpException) { + throw error; + } + + // Wrap unknown errors + throw new DomainHttpException( + ErrorCode.EXTERNAL_SERVICE_ERROR, + HttpStatus.BAD_GATEWAY, + `Salesforce ${context} failed` + ); + } + + /** + * Map Salesforce error codes to domain error codes + */ + private mapSalesforceErrorToDomain( + sfErrorCode: string, + message: string, + context: string + ): { code: ErrorCodeType; status: HttpStatus } { + // Not found errors + if ( + sfErrorCode === "NOT_FOUND" || + sfErrorCode === "INVALID_CROSS_REFERENCE_KEY" || + message.toLowerCase().includes("no rows") + ) { + return { code: ErrorCode.NOT_FOUND, status: HttpStatus.NOT_FOUND }; + } + + // Authentication errors + if ( + sfErrorCode === "INVALID_SESSION_ID" || + sfErrorCode === "SESSION_EXPIRED" || + sfErrorCode === "INVALID_AUTH_HEADER" + ) { + return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE }; + } + + // Validation errors + if ( + sfErrorCode === "REQUIRED_FIELD_MISSING" || + sfErrorCode === "INVALID_FIELD" || + sfErrorCode === "MALFORMED_ID" || + sfErrorCode === "FIELD_CUSTOM_VALIDATION_EXCEPTION" || + sfErrorCode === "FIELD_INTEGRITY_EXCEPTION" + ) { + return { code: ErrorCode.VALIDATION_FAILED, status: HttpStatus.BAD_REQUEST }; + } + + // Duplicate detection + if (sfErrorCode === "DUPLICATE_VALUE" || sfErrorCode === "DUPLICATE_EXTERNAL_ID") { + return { code: ErrorCode.ACCOUNT_EXISTS, status: HttpStatus.CONFLICT }; + } + + // Insufficient access + if ( + sfErrorCode === "INSUFFICIENT_ACCESS" || + sfErrorCode === "INSUFFICIENT_ACCESS_OR_READONLY" || + sfErrorCode === "CANNOT_INSERT_UPDATE_ACTIVATE_ENTITY" + ) { + return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.FORBIDDEN }; + } + + // Rate limiting + if (sfErrorCode === "REQUEST_LIMIT_EXCEEDED" || sfErrorCode === "TOO_MANY_REQUESTS") { + return { code: ErrorCode.RATE_LIMITED, status: HttpStatus.TOO_MANY_REQUESTS }; + } + + // Storage limit + if (sfErrorCode === "STORAGE_LIMIT_EXCEEDED") { + return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE }; + } + + // Default: external service error + void context; // reserved for future use + return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY }; + } + + /** + * Check if error is a session expired error + */ + isSessionExpiredError(error: unknown): boolean { + const message = extractErrorMessage(error).toLowerCase(); + return ( + message.includes("session expired") || + message.includes("invalid session") || + message.includes("invalid_session_id") + ); + } + + /** + * Check if error is a timeout error + */ + isTimeoutError(error: unknown): boolean { + const message = extractErrorMessage(error).toLowerCase(); + return ( + message.includes("timeout") || + message.includes("aborted") || + (error instanceof Error && error.name === "AbortError") + ); + } + + /** + * Check if error is a network error + */ + isNetworkError(error: unknown): boolean { + const message = extractErrorMessage(error).toLowerCase(); + return ( + message.includes("network") || + message.includes("connection") || + message.includes("econnrefused") || + message.includes("enotfound") || + message.includes("fetch failed") + ); + } + + /** + * Check if error is a rate limit error + */ + isRateLimitError(error: unknown): boolean { + const message = extractErrorMessage(error).toLowerCase(); + return ( + message.includes("rate limit") || + message.includes("too many requests") || + message.includes("request_limit_exceeded") + ); + } + + /** + * Get user-friendly error message + */ + getUserFriendlyMessage(error: unknown): string { + const message = extractErrorMessage(error); + + // Try common error patterns first + const commonResult = matchCommonError(message, "generic"); + if (commonResult.matched) { + return commonResult.message; + } + + // Salesforce-specific messages + if (this.isSessionExpiredError(error)) { + return "Service connection expired. Please try again."; + } + + if (message.toLowerCase().includes("no rows")) { + return "The requested record was not found."; + } + + return "An unexpected error occurred. Please try again later."; + } +} diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 9147d257..379fd93b 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -1,4 +1,5 @@ -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { chunkArray, sleep, extractErrorMessage } from "@bff/core/utils/index.js"; +import { matchCommonError, getDefaultMessage } from "@bff/core/errors/index.js"; import { Logger } from "nestjs-pino"; import { Injectable, NotFoundException, Inject } from "@nestjs/common"; import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js"; @@ -113,7 +114,7 @@ export class WhmcsInvoiceService { // Batch the invoice detail fetches to avoid N+1 overwhelming the API const invoicesWithItems: Invoice[] = []; - const batches = this.chunkArray(invoiceList.invoices, BATCH_SIZE); + const batches = chunkArray(invoiceList.invoices, BATCH_SIZE); for (let i = 0; i < batches.length; i++) { const batch = batches[i]; @@ -139,7 +140,7 @@ export class WhmcsInvoiceService { // Add delay between batches (except for the last batch) to respect rate limits if (i < batches.length - 1) { - await this.sleep(BATCH_DELAY_MS); + await sleep(BATCH_DELAY_MS); } } @@ -162,24 +163,6 @@ export class WhmcsInvoiceService { } } - /** - * Split an array into chunks of specified size - */ - private chunkArray(array: T[], size: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < array.length; i += size) { - chunks.push(array.slice(i, i + size)); - } - return chunks; - } - - /** - * Promise-based sleep for rate limiting - */ - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - /** * Get individual invoice by ID with caching */ @@ -492,35 +475,18 @@ export class WhmcsInvoiceService { */ private getUserFriendlyPaymentError(technicalError: string): string { if (!technicalError) { - return "Unable to process payment. Please try again or contact support."; + return getDefaultMessage("payment"); } + // First check common error patterns (timeout, network, auth, rate limit, permissions) + const commonResult = matchCommonError(technicalError, "payment"); + if (commonResult.matched) { + return commonResult.message; + } + + // Payment-specific error patterns const errorLower = technicalError.toLowerCase(); - // WHMCS API permission errors - if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) { - return "Payment processing is temporarily unavailable. Please contact support for assistance."; - } - - // Authentication/authorization errors - if ( - errorLower.includes("unauthorized") || - errorLower.includes("forbidden") || - errorLower.includes("403") - ) { - return "Payment processing is temporarily unavailable. Please contact support for assistance."; - } - - // Network/timeout errors - if ( - errorLower.includes("timeout") || - errorLower.includes("network") || - errorLower.includes("connection") - ) { - return "Payment processing timed out. Please try again."; - } - - // Payment method errors if ( errorLower.includes("payment method") || errorLower.includes("card") || @@ -529,12 +495,10 @@ export class WhmcsInvoiceService { return "Unable to process payment with your current payment method. Please check your payment details or try a different method."; } - // Generic API errors if (errorLower.includes("api") || errorLower.includes("http") || errorLower.includes("error")) { return "Payment processing failed. Please try again or contact support if the issue persists."; } - // Default fallback - return "Unable to process payment. Please try again or contact support."; + return getDefaultMessage("payment"); } } diff --git a/apps/bff/src/modules/me-status/me-status.service.ts b/apps/bff/src/modules/me-status/me-status.service.ts index b3d1d38e..d399f39b 100644 --- a/apps/bff/src/modules/me-status/me-status.service.ts +++ b/apps/bff/src/modules/me-status/me-status.service.ts @@ -2,7 +2,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { OrderOrchestrator } from "@bff/modules/orders/services/order-orchestrator.service.js"; -import { InternetEligibilityService } from "@bff/modules/services/services/internet-eligibility.service.js"; +import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js"; import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js"; diff --git a/apps/bff/src/modules/orders/services/checkout.service.ts b/apps/bff/src/modules/orders/services/checkout.service.ts index 94b3c859..26a4f989 100644 --- a/apps/bff/src/modules/orders/services/checkout.service.ts +++ b/apps/bff/src/modules/orders/services/checkout.service.ts @@ -21,10 +21,10 @@ import type { SimActivationFeeCatalogItem, VpnCatalogProduct, } from "@customer-portal/domain/services"; -import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js"; -import { InternetEligibilityService } from "@bff/modules/services/services/internet-eligibility.service.js"; -import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js"; -import { VpnServicesService } from "@bff/modules/services/services/vpn-services.service.js"; +import { InternetServicesService } from "@bff/modules/services/application/internet-services.service.js"; +import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js"; +import { SimServicesService } from "@bff/modules/services/application/sim-services.service.js"; +import { VpnServicesService } from "@bff/modules/services/application/vpn-services.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @Injectable() diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index 9a8327df..d2fcf26e 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -12,8 +12,8 @@ import { import type * as Providers from "@customer-portal/domain/subscriptions/providers"; type WhmcsProduct = Providers.WhmcsProductRaw; -import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js"; -import { InternetEligibilityService } from "@bff/modules/services/services/internet-eligibility.service.js"; +import { SimServicesService } from "@bff/modules/services/application/sim-services.service.js"; +import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js"; import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js"; import { PaymentValidatorService } from "./payment-validator.service.js"; import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js"; diff --git a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts index 85bc8865..15994e85 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -1,6 +1,6 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; +import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js"; import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { @@ -16,7 +16,7 @@ export interface SimFulfillmentRequest { @Injectable() export class SimFulfillmentService { constructor( - private readonly freebit: FreebitOrchestratorService, + private readonly freebit: FreebitOperationsService, @Inject(Logger) private readonly logger: Logger ) {} diff --git a/apps/bff/src/modules/services/account-services.controller.ts b/apps/bff/src/modules/services/account-services.controller.ts index 9c43147f..bf896a3d 100644 --- a/apps/bff/src/modules/services/account-services.controller.ts +++ b/apps/bff/src/modules/services/account-services.controller.ts @@ -9,9 +9,9 @@ import { type SimCatalogCollection, type VpnCatalogCollection, } from "@customer-portal/domain/services"; -import { InternetServicesService } from "./services/internet-services.service.js"; -import { SimServicesService } from "./services/sim-services.service.js"; -import { VpnServicesService } from "./services/vpn-services.service.js"; +import { InternetServicesService } from "./application/internet-services.service.js"; +import { SimServicesService } from "./application/sim-services.service.js"; +import { VpnServicesService } from "./application/vpn-services.service.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; @Controller("account/services") diff --git a/apps/bff/src/modules/services/services/base-services.service.ts b/apps/bff/src/modules/services/application/base-services.service.ts similarity index 100% rename from apps/bff/src/modules/services/services/base-services.service.ts rename to apps/bff/src/modules/services/application/base-services.service.ts diff --git a/apps/bff/src/modules/services/services/internet-eligibility.service.ts b/apps/bff/src/modules/services/application/internet-eligibility.service.ts similarity index 100% rename from apps/bff/src/modules/services/services/internet-eligibility.service.ts rename to apps/bff/src/modules/services/application/internet-eligibility.service.ts diff --git a/apps/bff/src/modules/services/services/internet-eligibility.types.ts b/apps/bff/src/modules/services/application/internet-eligibility.types.ts similarity index 100% rename from apps/bff/src/modules/services/services/internet-eligibility.types.ts rename to apps/bff/src/modules/services/application/internet-eligibility.types.ts diff --git a/apps/bff/src/modules/services/services/internet-services.service.ts b/apps/bff/src/modules/services/application/internet-services.service.ts similarity index 100% rename from apps/bff/src/modules/services/services/internet-services.service.ts rename to apps/bff/src/modules/services/application/internet-services.service.ts diff --git a/apps/bff/src/modules/services/services/services-cache.service.ts b/apps/bff/src/modules/services/application/services-cache.service.ts similarity index 100% rename from apps/bff/src/modules/services/services/services-cache.service.ts rename to apps/bff/src/modules/services/application/services-cache.service.ts diff --git a/apps/bff/src/modules/services/services/sim-services.service.ts b/apps/bff/src/modules/services/application/sim-services.service.ts similarity index 100% rename from apps/bff/src/modules/services/services/sim-services.service.ts rename to apps/bff/src/modules/services/application/sim-services.service.ts diff --git a/apps/bff/src/modules/services/services/vpn-services.service.ts b/apps/bff/src/modules/services/application/vpn-services.service.ts similarity index 100% rename from apps/bff/src/modules/services/services/vpn-services.service.ts rename to apps/bff/src/modules/services/application/vpn-services.service.ts diff --git a/apps/bff/src/modules/services/internet-eligibility.controller.ts b/apps/bff/src/modules/services/internet-eligibility.controller.ts index 8bc49671..b57fa06f 100644 --- a/apps/bff/src/modules/services/internet-eligibility.controller.ts +++ b/apps/bff/src/modules/services/internet-eligibility.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, Header, Post, Req, UseGuards } from "@nestjs/com import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; -import { InternetEligibilityService } from "./services/internet-eligibility.service.js"; +import { InternetEligibilityService } from "./application/internet-eligibility.service.js"; import type { InternetEligibilityDetails } from "@customer-portal/domain/services"; import { internetEligibilityDetailsSchema, diff --git a/apps/bff/src/modules/services/public-services.controller.ts b/apps/bff/src/modules/services/public-services.controller.ts index 57397b15..11b7b4de 100644 --- a/apps/bff/src/modules/services/public-services.controller.ts +++ b/apps/bff/src/modules/services/public-services.controller.ts @@ -9,9 +9,9 @@ import { type SimCatalogCollection, type VpnCatalogCollection, } from "@customer-portal/domain/services"; -import { InternetServicesService } from "./services/internet-services.service.js"; -import { SimServicesService } from "./services/sim-services.service.js"; -import { VpnServicesService } from "./services/vpn-services.service.js"; +import { InternetServicesService } from "./application/internet-services.service.js"; +import { SimServicesService } from "./application/sim-services.service.js"; +import { VpnServicesService } from "./application/vpn-services.service.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; @Controller("public/services") diff --git a/apps/bff/src/modules/services/services-health.controller.ts b/apps/bff/src/modules/services/services-health.controller.ts index c722b958..836bceeb 100644 --- a/apps/bff/src/modules/services/services-health.controller.ts +++ b/apps/bff/src/modules/services/services-health.controller.ts @@ -1,6 +1,6 @@ import { Controller, Get } from "@nestjs/common"; -import { ServicesCacheService } from "./services/services-cache.service.js"; -import type { ServicesCacheSnapshot } from "./services/services-cache.service.js"; +import { ServicesCacheService } from "./application/services-cache.service.js"; +import type { ServicesCacheSnapshot } from "./application/services-cache.service.js"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; interface ServicesCacheHealthResponse { diff --git a/apps/bff/src/modules/services/services.controller.ts b/apps/bff/src/modules/services/services.controller.ts index c49ed4a7..caed2463 100644 --- a/apps/bff/src/modules/services/services.controller.ts +++ b/apps/bff/src/modules/services/services.controller.ts @@ -15,9 +15,9 @@ import { type VpnCatalogProduct, type VpnCatalogCollection, } from "@customer-portal/domain/services"; -import { InternetServicesService } from "./services/internet-services.service.js"; -import { SimServicesService } from "./services/sim-services.service.js"; -import { VpnServicesService } from "./services/vpn-services.service.js"; +import { InternetServicesService } from "./application/internet-services.service.js"; +import { SimServicesService } from "./application/sim-services.service.js"; +import { VpnServicesService } from "./application/vpn-services.service.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; @Controller("services") diff --git a/apps/bff/src/modules/services/services.module.ts b/apps/bff/src/modules/services/services.module.ts index e0fb69dc..454f2963 100644 --- a/apps/bff/src/modules/services/services.module.ts +++ b/apps/bff/src/modules/services/services.module.ts @@ -10,12 +10,12 @@ import { CoreConfigModule } from "@bff/core/config/config.module.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js"; import { QueueModule } from "@bff/infra/queue/queue.module.js"; -import { BaseServicesService } from "./services/base-services.service.js"; -import { InternetServicesService } from "./services/internet-services.service.js"; -import { InternetEligibilityService } from "./services/internet-eligibility.service.js"; -import { SimServicesService } from "./services/sim-services.service.js"; -import { VpnServicesService } from "./services/vpn-services.service.js"; -import { ServicesCacheService } from "./services/services-cache.service.js"; +import { BaseServicesService } from "./application/base-services.service.js"; +import { InternetServicesService } from "./application/internet-services.service.js"; +import { InternetEligibilityService } from "./application/internet-eligibility.service.js"; +import { SimServicesService } from "./application/sim-services.service.js"; +import { VpnServicesService } from "./application/vpn-services.service.js"; +import { ServicesCacheService } from "./application/services-cache.service.js"; @Module({ imports: [ diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts index 61e6cbb3..b85b3532 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -21,10 +21,12 @@ import { SalesforceOpportunityService } from "@bff/integrations/salesforce/servi import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; import { EmailService } from "@bff/infra/email/email.service.js"; import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; -import type { - InternetCancellationPreview, - InternetCancellationMonth, - InternetCancelRequest, +import { + generateCancellationMonths, + getCancellationEffectiveDate, + type InternetCancellationPreview, + type InternetCancellationMonth, + type InternetCancelRequest, } from "@customer-portal/domain/subscriptions"; import { type InternetCancellationOpportunityData, @@ -46,33 +48,6 @@ export class InternetCancellationService { @Inject(Logger) private readonly logger: Logger ) {} - /** - * Generate available cancellation months (next 12 months) - * Following the 25th rule: if before 25th, current month is available - */ - private generateCancellationMonths(): InternetCancellationMonth[] { - const months: InternetCancellationMonth[] = []; - const today = new Date(); - const dayOfMonth = today.getDate(); - - // Start from current month if before 25th, otherwise next month - const startOffset = dayOfMonth <= 25 ? 0 : 1; - - for (let i = startOffset; i < startOffset + 12; i++) { - const date = new Date(today.getFullYear(), today.getMonth() + i, 1); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const monthStr = String(month).padStart(2, "0"); - - months.push({ - value: `${year}-${monthStr}`, - label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }), - }); - } - - return months; - } - /** * Validate that the subscription belongs to the user and is an Internet service */ @@ -163,7 +138,7 @@ export class InternetCancellationService { billingAmount: subscription.amount, nextDueDate: subscription.nextDue, registrationDate: subscription.registrationDate, - availableMonths: this.generateCancellationMonths(), + availableMonths: generateCancellationMonths() as InternetCancellationMonth[], customerEmail, customerName, }; @@ -189,21 +164,14 @@ export class InternetCancellationService { throw new BadRequestException("You must confirm both checkboxes to proceed"); } - // Parse cancellation month and calculate end date - const [year, month] = request.cancellationMonth.split("-").map(Number); - if (!year || !month) { + // Calculate cancellation date (end of selected month) + let cancellationDate: string; + try { + cancellationDate = getCancellationEffectiveDate(request.cancellationMonth); + } catch { throw new BadRequestException("Invalid cancellation month format"); } - // Cancellation date is end of selected month - const lastDayOfMonth = new Date(year, month, 0); - // Use local date components to avoid timezone shifts when converting to string - const cancellationDate = [ - lastDayOfMonth.getFullYear(), - String(lastDayOfMonth.getMonth() + 1).padStart(2, "0"), - String(lastDayOfMonth.getDate()).padStart(2, "0"), - ].join("-"); - this.logger.log("Processing Internet cancellation request", { userId, subscriptionId, diff --git a/apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.processor.ts b/apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.processor.ts index f6004d1c..7768ef71 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.processor.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.processor.ts @@ -3,7 +3,7 @@ import { Inject, Injectable } from "@nestjs/common"; import type { Job } from "bullmq"; import { Logger } from "nestjs-pino"; import { QUEUE_NAMES } from "@bff/infra/queue/queue.constants.js"; -import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; +import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { SIM_MANAGEMENT_JOB_NAMES as JOB_NAMES, @@ -14,7 +14,7 @@ import { @Injectable() export class SimManagementProcessor extends WorkerHost { constructor( - private readonly freebitService: FreebitOrchestratorService, + private readonly freebitService: FreebitOperationsService, @Inject(Logger) private readonly logger: Logger ) { super(); diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts index f85e6577..c2689be1 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts @@ -1,24 +1,22 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; -import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; +import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js"; import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SimValidationService } from "./sim-validation.service.js"; import { SimNotificationService } from "./sim-notification.service.js"; -import { SimApiNotificationService } from "./sim-api-notification.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import type { SimReissueRequest, SimReissueFullRequest } from "@customer-portal/domain/sim"; @Injectable() export class EsimManagementService { constructor( - private readonly freebitService: FreebitOrchestratorService, + private readonly freebitService: FreebitOperationsService, private readonly whmcsClientService: WhmcsClientService, private readonly mappingsService: MappingsService, private readonly simValidation: SimValidationService, private readonly simNotification: SimNotificationService, - private readonly apiNotification: SimApiNotificationService, private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger ) {} @@ -131,7 +129,7 @@ export class EsimManagementService { }); // Send API results email to admin - await this.apiNotification.sendApiResultsEmail("SIM Re-issue Request", [ + await this.simNotification.sendApiResultsEmail("SIM Re-issue Request", [ { url: `${this.freebitBaseUrl}/mvno/esim/addAcnt/`, json: { @@ -149,12 +147,12 @@ export class EsimManagementService { ]); // Send customer email - const customerEmailBody = this.apiNotification.buildEsimReissueEmail( + const customerEmailBody = this.simNotification.buildEsimReissueEmail( customerName, account, request.newEid ); - await this.apiNotification.sendCustomerEmail( + await this.simNotification.sendCustomerEmail( customerEmail, "SIM Re-issue Request", customerEmailBody @@ -176,18 +174,18 @@ export class EsimManagementService { }); // Send admin notification email - await this.apiNotification.sendApiResultsEmail( + await this.simNotification.sendApiResultsEmail( "Physical SIM Re-issue Request", [], `Physical SIM reissue requested for:\nCustomer: ${customerName}\nSIM #: ${account}\nSerial #: ${simDetails.iccid || "N/A"}\nEmail: ${customerEmail}` ); // Send customer email - const customerEmailBody = this.apiNotification.buildPhysicalSimReissueEmail( + const customerEmailBody = this.simNotification.buildPhysicalSimReissueEmail( customerName, account ); - await this.apiNotification.sendCustomerEmail( + await this.simNotification.sendCustomerEmail( customerEmail, "Physical SIM Re-issue Request", customerEmailBody diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-action-runner.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-action-runner.service.ts deleted file mode 100644 index c5d0384e..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-action-runner.service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import { SimNotificationService } from "./sim-notification.service.js"; -import type { SimNotificationContext } from "../interfaces/sim-base.interface.js"; - -interface RunOptions { - baseContext: SimNotificationContext; - enrichSuccess?: (result: T) => Partial; - enrichError?: (error: unknown) => Partial; -} - -@Injectable() -export class SimActionRunnerService { - constructor(private readonly simNotification: SimNotificationService) {} - - async run(action: string, options: RunOptions, handler: () => Promise): Promise { - try { - const result = await handler(); - const successContext = { - ...options.baseContext, - ...(options.enrichSuccess ? options.enrichSuccess(result) : {}), - }; - await this.simNotification.notifySimAction(action, "SUCCESS", successContext); - return result; - } catch (error) { - const errorContext = { - ...options.baseContext, - error: extractErrorMessage(error), - ...(options.enrichError ? options.enrichError(error) : {}), - }; - await this.simNotification.notifySimAction(action, "ERROR", errorContext); - throw error; - } - } -} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-api-notification.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-api-notification.service.ts deleted file mode 100644 index e215044c..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-api-notification.service.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Injectable, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { EmailService } from "@bff/infra/email/email.service.js"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; - -const ADMIN_EMAIL = "info@asolutions.co.jp"; - -export interface ApiCallLog { - url: string; - senddata?: Record | string; - json?: Record | string; - result: Record | string; -} - -@Injectable() -export class SimApiNotificationService { - constructor( - @Inject(Logger) private readonly logger: Logger, - private readonly email: EmailService - ) {} - - /** - * Send API results notification email to admin - */ - async sendApiResultsEmail( - subject: string, - apiCalls: ApiCallLog[], - additionalInfo?: string - ): Promise { - try { - const lines: string[] = []; - - for (const call of apiCalls) { - lines.push(`url: ${call.url}`); - lines.push(""); - - if (call.senddata) { - const senddataStr = - typeof call.senddata === "string" - ? call.senddata - : JSON.stringify(call.senddata, null, 2); - lines.push(`senddata: ${senddataStr}`); - lines.push(""); - } - - if (call.json) { - const jsonStr = - typeof call.json === "string" ? call.json : JSON.stringify(call.json, null, 2); - lines.push(`json: ${jsonStr}`); - lines.push(""); - } - - const resultStr = - typeof call.result === "string" ? call.result : JSON.stringify(call.result, null, 2); - lines.push(`result: ${resultStr}`); - lines.push(""); - lines.push("---"); - lines.push(""); - } - - if (additionalInfo) { - lines.push(additionalInfo); - } - - await this.email.sendEmail({ - to: ADMIN_EMAIL, - from: ADMIN_EMAIL, - subject, - text: lines.join("\n"), - }); - - this.logger.log("Sent API results notification email", { - subject, - to: ADMIN_EMAIL, - callCount: apiCalls.length, - }); - } catch (err) { - this.logger.warn("Failed to send API results notification email", { - subject, - error: extractErrorMessage(err), - }); - } - } - - /** - * Send customer notification email - */ - async sendCustomerEmail(to: string, subject: string, body: string): Promise { - try { - await this.email.sendEmail({ - to, - from: ADMIN_EMAIL, - subject, - text: body, - }); - - this.logger.log("Sent customer notification email", { - subject, - to, - }); - } catch (err) { - this.logger.warn("Failed to send customer notification email", { - subject, - to, - error: extractErrorMessage(err), - }); - } - } - - /** - * Build eSIM reissue customer email body - */ - buildEsimReissueEmail(customerName: string, simNumber: string, newEid: string): string { - return `Dear ${customerName}, - -This is to confirm that your request to re-issue the SIM card ${simNumber} -to the EID=${newEid} has been accepted. - -Please download the SIM plan, then follow the instructions to install the APN profile. - -eSIM plan download: https://www.asolutions.co.jp/uploads/pdf/esim.pdf -APN profile instructions: https://www.asolutions.co.jp/sim-card/ - -With best regards, -Assist Solutions Customer Support -TEL: 0120-660-470 (Mon-Fri / 10AM-6PM) -Email: ${ADMIN_EMAIL}`; - } - - /** - * Build physical SIM reissue customer email body - */ - buildPhysicalSimReissueEmail(customerName: string, simNumber: string): string { - return `Dear ${customerName}, - -This is to confirm that your request to re-issue the SIM card ${simNumber} -as a physical SIM has been accepted. - -You will be contacted by us again as soon as details about the shipping -schedule can be disclosed (typically in 3-5 business days). - -With best regards, -Assist Solutions Customer Support -TEL: 0120-660-470 (Mon-Fri / 10AM-6PM) -Email: ${ADMIN_EMAIL}`; - } - - /** - * Build cancellation notification email body for admin - */ - buildCancellationAdminEmail(params: { - customerName: string; - simNumber: string; - serialNumber?: string; - cancellationMonth: string; - registeredEmail: string; - comments?: string; - }): string { - return `The following SONIXNET SIM cancellation has been requested. - -Customer name: ${params.customerName} -SIM #: ${params.simNumber} -Serial #: ${params.serialNumber || "N/A"} -Cancellation month: ${params.cancellationMonth} -Registered email address: ${params.registeredEmail} -Comments: ${params.comments || "N/A"}`; - } -} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts new file mode 100644 index 00000000..30a55737 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts @@ -0,0 +1,84 @@ +import { Injectable } from "@nestjs/common"; + +/** + * Service for formatting call history data for display. + * Handles time, duration, and phone number formatting. + */ +@Injectable() +export class SimCallHistoryFormatterService { + /** + * Convert HHMMSS to HH:MM:SS display format + */ + formatTime(timeStr: string): string { + if (!timeStr || timeStr.length < 6) return timeStr; + const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0"); + return `${clean.substring(0, 2)}:${clean.substring(2, 4)}:${clean.substring(4, 6)}`; + } + + /** + * Convert seconds to readable duration format (Xh Ym Zs) + */ + formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s`; + } else if (minutes > 0) { + return `${minutes}m ${secs}s`; + } else { + return `${secs}s`; + } + } + + /** + * Format Japanese phone numbers with appropriate separators + */ + formatPhoneNumber(phone: string): string { + if (!phone) return phone; + const clean = phone.replace(/[^0-9+]/g, ""); + + // 080-XXXX-XXXX or 070-XXXX-XXXX or 090-XXXX-XXXX format + if ( + clean.length === 11 && + (clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090")) + ) { + return `${clean.substring(0, 3)}-${clean.substring(3, 7)}-${clean.substring(7)}`; + } + + // 03-XXXX-XXXX format (landline) + if (clean.length === 10 && clean.startsWith("0")) { + return `${clean.substring(0, 2)}-${clean.substring(2, 6)}-${clean.substring(6)}`; + } + + return clean; + } + + /** + * Get default month for call history queries (2 months ago) + */ + getDefaultMonth(): string { + const now = new Date(); + now.setMonth(now.getMonth() - 2); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; + } + + /** + * Format a date to YYYY-MM format + */ + formatYearMonth(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; + } + + /** + * Convert YYYYMM to YYYY-MM format + */ + normalizeMonth(yearMonth: string): string { + return `${yearMonth.substring(0, 4)}-${yearMonth.substring(4, 6)}`; + } +} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts new file mode 100644 index 00000000..a51277c6 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts @@ -0,0 +1,295 @@ +import { Injectable } from "@nestjs/common"; + +// SmsType enum to match Prisma schema +type SmsType = "DOMESTIC" | "INTERNATIONAL"; + +export interface DomesticCallRecord { + account: string; + callDate: Date; + callTime: string; + calledTo: string; + location: string | null; + durationSec: number; + chargeYen: number; + month: string; +} + +export interface InternationalCallRecord { + account: string; + callDate: Date; + startTime: string; + stopTime: string | null; + country: string | null; + calledTo: string; + durationSec: number; + chargeYen: number; + month: string; +} + +export interface SmsRecord { + account: string; + smsDate: Date; + smsTime: string; + sentTo: string; + smsType: SmsType; + month: string; +} + +export interface ParsedTalkDetail { + domestic: DomesticCallRecord[]; + international: InternationalCallRecord[]; + skipped: number; + errors: number; +} + +export interface ParsedSmsDetail { + records: SmsRecord[]; + skipped: number; + errors: number; +} + +/** + * Service for parsing call history CSV files from Freebit SFTP. + * Handles the specific CSV formats for talk detail and SMS detail files. + * + * Talk Detail CSV Columns: + * 1. Customer phone number + * 2. Date (YYYYMMDD) + * 3. Start time (HHMMSS) + * 4. Called to phone number + * 5. dome/tointl (call type) + * 6. Location + * 7. Duration (MMSST format - minutes, seconds, tenths) + * 8. Tokens (each token = 10 yen) + * 9. Alternative charge (if location is "他社") + * + * SMS Detail CSV Columns: + * 1. Customer phone number + * 2. Date (YYYYMMDD) + * 3. Start time (HHMMSS) + * 4. SMS sent to phone number + * 5. dome/tointl + * 6. SMS type (SMS or 国際SMS) + */ +@Injectable() +export class SimCallHistoryParserService { + /** + * Parse talk detail CSV content into domestic and international call records + */ + parseTalkDetailCsv(content: string, month: string): ParsedTalkDetail { + const domestic: DomesticCallRecord[] = []; + const international: InternationalCallRecord[] = []; + let skipped = 0; + let errors = 0; + + const lines = content.split(/\r?\n/).filter(line => line.trim()); + + for (const line of lines) { + try { + const columns = this.parseCsvLine(line); + + if (columns.length < 8) { + skipped++; + continue; + } + + const [ + phoneNumber, + dateStr, + timeStr, + calledTo, + callType, + location, + durationStr, + tokensStr, + altChargeStr, + ] = columns; + + // Parse date + const callDate = this.parseDate(dateStr); + if (!callDate) { + skipped++; + continue; + } + + // Parse duration - format is MMSST (minutes, seconds, tenths) + // e.g., 36270 = 36 min 27.0 sec, 320 = 0 min 32.0 sec + const durationSec = this.parseDuration(durationStr); + + // Parse charge: use tokens * 10 yen, or alt charge if location is "他社" + const chargeYen = this.parseCharge(location, tokensStr, altChargeStr); + + // Clean account number (remove dashes, spaces) + const account = this.cleanPhoneNumber(phoneNumber); + + // Clean called-to number + const cleanCalledTo = this.cleanPhoneNumber(calledTo); + + if (callType === "dome" || callType === "domestic") { + domestic.push({ + account, + callDate, + callTime: timeStr, + calledTo: cleanCalledTo, + location: location || null, + durationSec, + chargeYen, + month, + }); + } else if (callType === "tointl" || callType === "international") { + international.push({ + account, + callDate, + startTime: timeStr, + stopTime: null, + country: location || null, + calledTo: cleanCalledTo, + durationSec, + chargeYen, + month, + }); + } else { + skipped++; + } + } catch { + errors++; + } + } + + return { domestic, international, skipped, errors }; + } + + /** + * Parse SMS detail CSV content + */ + parseSmsDetailCsv(content: string, month: string): ParsedSmsDetail { + const records: SmsRecord[] = []; + let skipped = 0; + let errors = 0; + + const lines = content.split(/\r?\n/).filter(line => line.trim()); + + for (const line of lines) { + try { + const columns = this.parseCsvLine(line); + + if (columns.length < 6) { + skipped++; + continue; + } + + const [phoneNumber, dateStr, timeStr, sentTo, , smsTypeStr] = columns; + + // Parse date + const smsDate = this.parseDate(dateStr); + if (!smsDate) { + skipped++; + continue; + } + + // Clean account number + const account = this.cleanPhoneNumber(phoneNumber); + + // Clean sent-to number + const cleanSentTo = this.cleanPhoneNumber(sentTo); + + // Determine SMS type + const smsType: SmsType = smsTypeStr.includes("国際") ? "INTERNATIONAL" : "DOMESTIC"; + + records.push({ + account, + smsDate, + smsTime: timeStr, + sentTo: cleanSentTo, + smsType, + month, + }); + } catch { + errors++; + } + } + + return { records, skipped, errors }; + } + + /** + * Parse a CSV line handling quoted fields and escaped quotes + */ + private parseCsvLine(line: string): string[] { + const normalizedLine = line.replace(/\r$/, "").replace(/^\uFEFF/, ""); + const result: string[] = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < normalizedLine.length; i++) { + const char = normalizedLine[i]; + if (char === '"') { + if (inQuotes && normalizedLine[i + 1] === '"') { + current += '"'; + i++; + continue; + } + inQuotes = !inQuotes; + continue; + } + if (char === "," && !inQuotes) { + result.push(current.trim()); + current = ""; + continue; + } + current += char; + } + result.push(current.trim()); + + return result; + } + + /** + * Parse YYYYMMDD date string to Date object + */ + private parseDate(dateStr: string): Date | null { + if (!dateStr || dateStr.length < 8) return null; + + const clean = dateStr.replace(/[^0-9]/g, ""); + if (clean.length < 8) return null; + + const year = parseInt(clean.substring(0, 4), 10); + const month = parseInt(clean.substring(4, 6), 10) - 1; + const day = parseInt(clean.substring(6, 8), 10); + + if (isNaN(year) || isNaN(month) || isNaN(day)) return null; + + return new Date(year, month, day); + } + + /** + * Parse duration string (MMSST format) to seconds + */ + private parseDuration(durationStr: string): number { + const durationVal = durationStr.padStart(5, "0"); + const minutes = parseInt(durationVal.slice(0, -3), 10) || 0; + const seconds = parseInt(durationVal.slice(-3, -1), 10) || 0; + return minutes * 60 + seconds; + } + + /** + * Parse charge from tokens or alternative charge + */ + private parseCharge( + location: string | undefined, + tokensStr: string, + altChargeStr: string | undefined + ): number { + if (location && location.includes("他社") && altChargeStr) { + return parseInt(altChargeStr, 10) || 0; + } + return (parseInt(tokensStr, 10) || 0) * 10; + } + + /** + * Clean phone number by removing dashes and spaces + */ + private cleanPhoneNumber(phone: string): string { + return phone.replace(/[-\s]/g, ""); + } +} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts index 5930f984..86bfbc79 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts @@ -3,47 +3,24 @@ import { Logger } from "nestjs-pino"; import { PrismaService } from "@bff/infra/database/prisma.service.js"; import { SftpClientService } from "@bff/integrations/sftp/sftp-client.service.js"; import { SimValidationService } from "./sim-validation.service.js"; +import { SimCallHistoryParserService } from "./sim-call-history-parser.service.js"; +import { SimCallHistoryFormatterService } from "./sim-call-history-formatter.service.js"; import type { SimDomesticCallHistoryResponse, SimInternationalCallHistoryResponse, SimSmsHistoryResponse, } from "@customer-portal/domain/sim"; +// Re-export types for consumers +export type { + DomesticCallRecord, + InternationalCallRecord, + SmsRecord, +} from "./sim-call-history-parser.service.js"; + // SmsType enum to match Prisma schema type SmsType = "DOMESTIC" | "INTERNATIONAL"; -export interface DomesticCallRecord { - account: string; - callDate: Date; - callTime: string; - calledTo: string; - location: string | null; - durationSec: number; - chargeYen: number; - month: string; -} - -export interface InternationalCallRecord { - account: string; - callDate: Date; - startTime: string; - stopTime: string | null; - country: string | null; - calledTo: string; - durationSec: number; - chargeYen: number; - month: string; -} - -export interface SmsRecord { - account: string; - smsDate: Date; - smsTime: string; - sentTo: string; - smsType: SmsType; - month: string; -} - export interface CallHistoryPagination { page: number; limit: number; @@ -51,180 +28,21 @@ export interface CallHistoryPagination { totalPages: number; } +/** + * Service for managing SIM call history data. + * Coordinates importing data from SFTP and querying stored history. + */ @Injectable() export class SimCallHistoryService { constructor( private readonly prisma: PrismaService, private readonly sftp: SftpClientService, private readonly simValidation: SimValidationService, + private readonly parser: SimCallHistoryParserService, + private readonly formatter: SimCallHistoryFormatterService, @Inject(Logger) private readonly logger: Logger ) {} - /** - * Parse talk detail CSV content - * Columns: - * 1. Customer phone number - * 2. Date (YYYYMMDD) - * 3. Start time (HHMMSS) - * 4. Called to phone number - * 5. dome/tointl - * 6. Location - * 7. Duration (320 = 32.0 seconds) - * 8. Tokens (each token = 10 yen) - * 9. Alternative charge (if column 6 says "他社") - */ - parseTalkDetailCsv( - content: string, - month: string - ): { domestic: DomesticCallRecord[]; international: InternationalCallRecord[] } { - const domestic: DomesticCallRecord[] = []; - const international: InternationalCallRecord[] = []; - - const lines = content.split(/\r?\n/).filter(line => line.trim()); - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - try { - // Parse CSV line - handle potential commas in values - const columns = this.parseCsvLine(line); - - if (columns.length < 8) { - this.logger.debug(`Skipping line ${i + 1}: insufficient columns`, { line }); - continue; - } - - const [ - phoneNumber, - dateStr, - timeStr, - calledTo, - callType, - location, - durationStr, - tokensStr, - altChargeStr, - ] = columns; - - // Parse date - const callDate = this.parseDate(dateStr); - if (!callDate) { - this.logger.debug(`Skipping line ${i + 1}: invalid date`, { dateStr }); - continue; - } - - // Parse duration - format is MMSST (minutes, seconds, tenths) - // e.g., 36270 = 36 min 27.0 sec, 320 = 0 min 32.0 sec - const durationVal = durationStr.padStart(5, "0"); // Ensure at least 5 digits - const minutes = parseInt(durationVal.slice(0, -3), 10) || 0; // All but last 3 digits - const seconds = parseInt(durationVal.slice(-3, -1), 10) || 0; // 2 digits before last - // Last digit is tenths, which we ignore - const durationSec = minutes * 60 + seconds; - - // Parse charge: use tokens * 10 yen, or alt charge if location is "他社" - let chargeYen: number; - if (location && location.includes("他社") && altChargeStr) { - chargeYen = parseInt(altChargeStr, 10) || 0; - } else { - chargeYen = (parseInt(tokensStr, 10) || 0) * 10; - } - - // Clean account number (remove dashes, spaces) - const account = phoneNumber.replace(/[-\s]/g, ""); - - // Clean called-to number - const cleanCalledTo = calledTo.replace(/[-\s]/g, ""); - - if (callType === "dome" || callType === "domestic") { - domestic.push({ - account, - callDate, - callTime: timeStr, - calledTo: cleanCalledTo, - location: location || null, - durationSec, - chargeYen, - month, - }); - } else if (callType === "tointl" || callType === "international") { - international.push({ - account, - callDate, - startTime: timeStr, - stopTime: null, // Could be calculated from duration if needed - country: location || null, - calledTo: cleanCalledTo, - durationSec, - chargeYen, - month, - }); - } - } catch (error) { - this.logger.warn(`Failed to parse talk detail line ${i + 1}`, { line, error }); - } - } - - return { domestic, international }; - } - - /** - * Parse SMS detail CSV content - * Columns: - * 1. Customer phone number - * 2. Date (YYYYMMDD) - * 3. Start time (HHMMSS) - * 4. SMS sent to phone number - * 5. dome/tointl - * 6. SMS type (SMS or 国際SMS) - */ - parseSmsDetailCsv(content: string, month: string): SmsRecord[] { - const records: SmsRecord[] = []; - - const lines = content.split(/\r?\n/).filter(line => line.trim()); - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - try { - const columns = this.parseCsvLine(line); - - if (columns.length < 6) { - this.logger.debug(`Skipping SMS line ${i + 1}: insufficient columns`, { line }); - continue; - } - - const [phoneNumber, dateStr, timeStr, sentTo, , smsTypeStr] = columns; - - // Parse date - const smsDate = this.parseDate(dateStr); - if (!smsDate) { - this.logger.debug(`Skipping SMS line ${i + 1}: invalid date`, { dateStr }); - continue; - } - - // Clean account number - const account = phoneNumber.replace(/[-\s]/g, ""); - - // Clean sent-to number - const cleanSentTo = sentTo.replace(/[-\s]/g, ""); - - // Determine SMS type - const smsType: SmsType = smsTypeStr.includes("国際") ? "INTERNATIONAL" : "DOMESTIC"; - - records.push({ - account, - smsDate, - smsTime: timeStr, - sentTo: cleanSentTo, - smsType, - month, - }); - } catch (error) { - this.logger.warn(`Failed to parse SMS detail line ${i + 1}`, { line, error }); - } - } - - return records; - } - /** * Import call history from SFTP for a specific month */ @@ -233,7 +51,7 @@ export class SimCallHistoryService { international: number; sms: number; }> { - const month = `${yearMonth.substring(0, 4)}-${yearMonth.substring(4, 6)}`; + const month = this.formatter.normalizeMonth(yearMonth); this.logger.log(`Starting call history import for ${month}`); @@ -247,13 +65,20 @@ export class SimCallHistoryService { let internationalCount = 0; let smsCount = 0; + // Import talk detail (calls) try { - // Download and parse talk detail const talkContent = await this.sftp.downloadTalkDetail(yearMonth); - const { domestic, international } = this.parseTalkDetailCsv(talkContent, month); + const parsed = this.parser.parseTalkDetailCsv(talkContent, month); + + this.logger.log(`Parsed talk detail`, { + domestic: parsed.domestic.length, + international: parsed.international.length, + skipped: parsed.skipped, + errors: parsed.errors, + }); domesticCount = await this.processInBatches( - domestic, + parsed.domestic, 50, record => this.prisma.simCallHistoryDomestic.upsert({ @@ -277,7 +102,7 @@ export class SimCallHistoryService { ); internationalCount = await this.processInBatches( - international, + parsed.international, 50, record => this.prisma.simCallHistoryInternational.upsert({ @@ -308,13 +133,19 @@ export class SimCallHistoryService { this.logger.error(`Failed to import talk detail`, { error, yearMonth }); } + // Import SMS detail try { - // Download and parse SMS detail const smsContent = await this.sftp.downloadSmsDetail(yearMonth); - const smsRecords = this.parseSmsDetailCsv(smsContent, month); + const parsed = this.parser.parseSmsDetailCsv(smsContent, month); + + this.logger.log(`Parsed SMS detail`, { + records: parsed.records.length, + skipped: parsed.skipped, + errors: parsed.errors, + }); smsCount = await this.processInBatches( - smsRecords, + parsed.records, 50, record => this.prisma.simSmsHistory.upsert({ @@ -373,30 +204,22 @@ export class SimCallHistoryService { page: number = 1, limit: number = 50 ): Promise { - // Validate subscription ownership await this.simValidation.validateSimSubscription(userId, subscriptionId); // Dev/testing mode: call history data is currently sourced from a fixed account. // TODO: Replace with the validated subscription account once call history data is available per user. const account = "08077052946"; - // Default to available month if not specified - const targetMonth = month || this.getDefaultMonth(); + const targetMonth = month || this.formatter.getDefaultMonth(); const [calls, total] = await Promise.all([ this.prisma.simCallHistoryDomestic.findMany({ - where: { - account, - month: targetMonth, - }, + where: { account, month: targetMonth }, orderBy: [{ callDate: "desc" }, { callTime: "desc" }], skip: (page - 1) * limit, take: limit, }), this.prisma.simCallHistoryDomestic.count({ - where: { - account, - month: targetMonth, - }, + where: { account, month: targetMonth }, }), ]); @@ -412,9 +235,9 @@ export class SimCallHistoryService { }) => ({ id: call.id, date: call.callDate.toISOString().split("T")[0], - time: this.formatTime(call.callTime), - calledTo: this.formatPhoneNumber(call.calledTo), - callLength: this.formatDuration(call.durationSec), + time: this.formatter.formatTime(call.callTime), + calledTo: this.formatter.formatPhoneNumber(call.calledTo), + callLength: this.formatter.formatDuration(call.durationSec), callCharge: call.chargeYen, }) ), @@ -438,30 +261,20 @@ export class SimCallHistoryService { page: number = 1, limit: number = 50 ): Promise { - // Validate subscription ownership await this.simValidation.validateSimSubscription(userId, subscriptionId); - // Dev/testing mode: call history data is currently sourced from a fixed account. - // TODO: Replace with the validated subscription account once call history data is available per user. const account = "08077052946"; - // Default to available month if not specified - const targetMonth = month || this.getDefaultMonth(); + const targetMonth = month || this.formatter.getDefaultMonth(); const [calls, total] = await Promise.all([ this.prisma.simCallHistoryInternational.findMany({ - where: { - account, - month: targetMonth, - }, + where: { account, month: targetMonth }, orderBy: [{ callDate: "desc" }, { startTime: "desc" }], skip: (page - 1) * limit, take: limit, }), this.prisma.simCallHistoryInternational.count({ - where: { - account, - month: targetMonth, - }, + where: { account, month: targetMonth }, }), ]); @@ -478,10 +291,10 @@ export class SimCallHistoryService { }) => ({ id: call.id, date: call.callDate.toISOString().split("T")[0], - startTime: this.formatTime(call.startTime), - stopTime: call.stopTime ? this.formatTime(call.stopTime) : null, + startTime: this.formatter.formatTime(call.startTime), + stopTime: call.stopTime ? this.formatter.formatTime(call.stopTime) : null, country: call.country, - calledTo: this.formatPhoneNumber(call.calledTo), + calledTo: this.formatter.formatPhoneNumber(call.calledTo), callCharge: call.chargeYen, }) ), @@ -505,30 +318,20 @@ export class SimCallHistoryService { page: number = 1, limit: number = 50 ): Promise { - // Validate subscription ownership await this.simValidation.validateSimSubscription(userId, subscriptionId); - // Dev/testing mode: call history data is currently sourced from a fixed account. - // TODO: Replace with the validated subscription account once call history data is available per user. const account = "08077052946"; - // Default to available month if not specified - const targetMonth = month || this.getDefaultMonth(); + const targetMonth = month || this.formatter.getDefaultMonth(); const [messages, total] = await Promise.all([ this.prisma.simSmsHistory.findMany({ - where: { - account, - month: targetMonth, - }, + where: { account, month: targetMonth }, orderBy: [{ smsDate: "desc" }, { smsTime: "desc" }], skip: (page - 1) * limit, take: limit, }), this.prisma.simSmsHistory.count({ - where: { - account, - month: targetMonth, - }, + where: { account, month: targetMonth }, }), ]); @@ -543,8 +346,8 @@ export class SimCallHistoryService { }) => ({ id: msg.id, date: msg.smsDate.toISOString().split("T")[0], - time: this.formatTime(msg.smsTime), - sentTo: this.formatPhoneNumber(msg.sentTo), + time: this.formatter.formatTime(msg.smsTime), + sentTo: this.formatter.formatPhoneNumber(msg.sentTo), type: msg.smsType === "INTERNATIONAL" ? "International SMS" : "SMS", }) ), @@ -584,38 +387,9 @@ export class SimCallHistoryService { } } - // Helper methods - - private parseCsvLine(line: string): string[] { - const normalizedLine = line.replace(/\r$/, "").replace(/^\uFEFF/, ""); - // CSV parsing with quoted field and escaped quote support - const result: string[] = []; - let current = ""; - let inQuotes = false; - - for (let i = 0; i < normalizedLine.length; i++) { - const char = normalizedLine[i]; - if (char === '"') { - if (inQuotes && normalizedLine[i + 1] === '"') { - current += '"'; - i++; - continue; - } - inQuotes = !inQuotes; - continue; - } - if (char === "," && !inQuotes) { - result.push(current.trim()); - current = ""; - continue; - } - current += char; - } - result.push(current.trim()); - - return result; - } - + /** + * Process records in batches with error handling + */ private async processInBatches( records: T[], batchSize: number, @@ -639,71 +413,4 @@ export class SimCallHistoryService { return successCount; } - - private parseDate(dateStr: string): Date | null { - if (!dateStr || dateStr.length < 8) return null; - - // Clean the string - const clean = dateStr.replace(/[^0-9]/g, ""); - if (clean.length < 8) return null; - - const year = parseInt(clean.substring(0, 4), 10); - const month = parseInt(clean.substring(4, 6), 10) - 1; - const day = parseInt(clean.substring(6, 8), 10); - - if (isNaN(year) || isNaN(month) || isNaN(day)) return null; - - return new Date(year, month, day); - } - - private formatTime(timeStr: string): string { - // Convert HHMMSS to HH:MM:SS - if (!timeStr || timeStr.length < 6) return timeStr; - const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0"); - return `${clean.substring(0, 2)}:${clean.substring(2, 4)}:${clean.substring(4, 6)}`; - } - - private formatDuration(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}h ${minutes}m ${secs}s`; - } else if (minutes > 0) { - return `${minutes}m ${secs}s`; - } else { - return `${secs}s`; - } - } - - private formatPhoneNumber(phone: string): string { - // Format Japanese phone numbers - if (!phone) return phone; - const clean = phone.replace(/[^0-9+]/g, ""); - - // 080-XXXX-XXXX or 070-XXXX-XXXX format - if ( - clean.length === 11 && - (clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090")) - ) { - return `${clean.substring(0, 3)}-${clean.substring(3, 7)}-${clean.substring(7)}`; - } - - // 03-XXXX-XXXX format - if (clean.length === 10 && clean.startsWith("0")) { - return `${clean.substring(0, 2)}-${clean.substring(2, 6)}-${clean.substring(6)}`; - } - - return clean; - } - - private getDefaultMonth(): string { - // Default to 2 months ago (available data) - const now = new Date(); - now.setMonth(now.getMonth() - 2); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - return `${year}-${month}`; - } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index 9d3b5393..b2f0297a 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts @@ -1,7 +1,7 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; -import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; +import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js"; import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; @@ -13,26 +13,29 @@ import type { SimCancellationMonth, SimCancellationPreview, } from "@customer-portal/domain/sim"; +import { + generateCancellationMonths, + getCancellationEffectiveDate, + getRunDateFromMonth, +} from "@customer-portal/domain/subscriptions"; import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; import { SIM_CANCELLATION_NOTICE } from "@customer-portal/domain/opportunity"; import { SimScheduleService } from "./sim-schedule.service.js"; -import { SimActionRunnerService } from "./sim-action-runner.service.js"; -import { SimApiNotificationService } from "./sim-api-notification.service.js"; +import { SimNotificationService } from "./sim-notification.service.js"; import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; @Injectable() export class SimCancellationService { constructor( - private readonly freebitService: FreebitOrchestratorService, + private readonly freebitService: FreebitOperationsService, private readonly whmcsClientService: WhmcsClientService, private readonly mappingsService: MappingsService, private readonly opportunityService: SalesforceOpportunityService, private readonly caseService: SalesforceCaseService, private readonly simValidation: SimValidationService, private readonly simSchedule: SimScheduleService, - private readonly simActionRunner: SimActionRunnerService, - private readonly apiNotification: SimApiNotificationService, + private readonly simNotification: SimNotificationService, private readonly notifications: NotificationService, private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger @@ -42,38 +45,6 @@ export class SimCancellationService { return this.configService.get("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api"; } - /** - * Generate available cancellation months (next 12 months) - */ - private generateCancellationMonths(): SimCancellationMonth[] { - const months: SimCancellationMonth[] = []; - const today = new Date(); - const dayOfMonth = today.getDate(); - - // Start from current month if before 25th, otherwise next month - const startOffset = dayOfMonth <= 25 ? 0 : 1; - - for (let i = startOffset; i < startOffset + 12; i++) { - const date = new Date(today.getFullYear(), today.getMonth() + i, 1); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const monthStr = String(month).padStart(2, "0"); - - // runDate is the 1st of the NEXT month (cancellation takes effect at month end) - const nextMonth = new Date(year, month, 1); - const runYear = nextMonth.getFullYear(); - const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0"); - - months.push({ - value: `${year}-${monthStr}`, - label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }), - runDate: `${runYear}${runMonth}01`, - }); - } - - return months; - } - /** * Calculate minimum contract end date (3 months after start, signup month not included) */ @@ -133,7 +104,9 @@ export class SimCancellationService { startDate, minimumContractEndDate, isWithinMinimumTerm, - availableMonths: this.generateCancellationMonths(), + availableMonths: generateCancellationMonths({ + includeRunDate: true, + }) as SimCancellationMonth[], customerEmail, customerName, }; @@ -149,7 +122,7 @@ export class SimCancellationService { ): Promise { let account = ""; - await this.simActionRunner.run( + await this.simNotification.runWithNotification( "Cancel SIM", { baseContext: { @@ -223,26 +196,16 @@ export class SimCancellationService { throw new BadRequestException("You must confirm both checkboxes to proceed"); } - // Parse cancellation month and calculate runDate - const [year, month] = request.cancellationMonth.split("-").map(Number); - if (!year || !month) { + // Calculate runDate and cancellation date using shared utilities + let runDate: string; + let cancellationDate: string; + try { + runDate = getRunDateFromMonth(request.cancellationMonth); + cancellationDate = getCancellationEffectiveDate(request.cancellationMonth); + } catch { throw new BadRequestException("Invalid cancellation month format"); } - // runDate is 1st of the NEXT month (cancellation at end of selected month) - const nextMonth = new Date(year, month, 1); - const runYear = nextMonth.getFullYear(); - const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0"); - const runDate = `${runYear}${runMonth}01`; - - // Calculate the cancellation date (last day of selected month) - const lastDayOfMonth = new Date(year, month, 0); - const cancellationDate = [ - lastDayOfMonth.getFullYear(), - String(lastDayOfMonth.getMonth() + 1).padStart(2, "0"), - String(lastDayOfMonth.getDate()).padStart(2, "0"), - ].join("-"); - this.logger.log(`Processing SIM cancellation via PA02-04`, { userId, subscriptionId, @@ -358,7 +321,7 @@ export class SimCancellationService { } // Send admin notification email - const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({ + const adminEmailBody = this.simNotification.buildCancellationAdminEmail({ customerName, simNumber: account, serialNumber: simDetails.iccid, @@ -367,7 +330,7 @@ export class SimCancellationService { comments: request.comments, }); - await this.apiNotification.sendApiResultsEmail( + await this.simNotification.sendApiResultsEmail( "SonixNet SIM Online Cancellation", [ { @@ -402,7 +365,7 @@ Assist Solutions Customer Support TEL: 0120-660-470 (Mon-Fri / 10AM-6PM) Email: info@asolutions.co.jp`; - await this.apiNotification.sendCustomerEmail( + await this.simNotification.sendCustomerEmail( customerEmail, confirmationSubject, confirmationBody diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-details.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-details.service.ts index e37a0dce..5ca9124c 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-details.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-details.service.ts @@ -1,6 +1,6 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; +import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js"; import { SimValidationService } from "./sim-validation.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import type { SimDetails } from "@customer-portal/domain/sim"; @@ -8,7 +8,7 @@ import type { SimDetails } from "@customer-portal/domain/sim"; @Injectable() export class SimDetailsService { constructor( - private readonly freebitService: FreebitOrchestratorService, + private readonly freebitService: FreebitOperationsService, private readonly simValidation: SimValidationService, @Inject(Logger) private readonly logger: Logger ) {} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts index 17afb8f7..e50c7f38 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts @@ -5,6 +5,25 @@ import { EmailService } from "@bff/infra/email/email.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import type { SimNotificationContext } from "../interfaces/sim-base.interface.js"; +const ADMIN_EMAIL = "info@asolutions.co.jp"; + +/** + * API call log structure for notification emails + */ +export interface ApiCallLog { + url: string; + senddata?: Record | string; + json?: Record | string; + result: Record | string; +} + +/** + * Unified SIM notification service. + * Handles all SIM-related email notifications including: + * - Internal action notifications (success/error alerts) + * - API results notifications to admin + * - Customer-facing emails (eSIM reissue, cancellation confirmations) + */ @Injectable() export class SimNotificationService { constructor( @@ -13,8 +32,12 @@ export class SimNotificationService { private readonly configService: ConfigService ) {} + // ============================================================================ + // Internal Action Notifications + // ============================================================================ + /** - * Send notification for SIM actions + * Send notification for SIM actions to configured alert email */ async notifySimAction( action: string, @@ -59,27 +82,169 @@ export class SimNotificationService { } } + // ============================================================================ + // API Results Notifications (Admin) + // ============================================================================ + /** - * Redact sensitive information from notification context + * Send API results notification email to admin */ - private redactSensitiveFields(context: Record): Record { - const sanitized: Record = {}; - for (const [key, value] of Object.entries(context)) { - if (typeof key === "string" && key.toLowerCase().includes("password")) { - sanitized[key] = "[REDACTED]"; - continue; + async sendApiResultsEmail( + subject: string, + apiCalls: ApiCallLog[], + additionalInfo?: string + ): Promise { + try { + const lines: string[] = []; + + for (const call of apiCalls) { + lines.push(`url: ${call.url}`); + lines.push(""); + + if (call.senddata) { + const senddataStr = + typeof call.senddata === "string" + ? call.senddata + : JSON.stringify(call.senddata, null, 2); + lines.push(`senddata: ${senddataStr}`); + lines.push(""); + } + + if (call.json) { + const jsonStr = + typeof call.json === "string" ? call.json : JSON.stringify(call.json, null, 2); + lines.push(`json: ${jsonStr}`); + lines.push(""); + } + + const resultStr = + typeof call.result === "string" ? call.result : JSON.stringify(call.result, null, 2); + lines.push(`result: ${resultStr}`); + lines.push(""); + lines.push("---"); + lines.push(""); } - if (typeof value === "string" && value.length > 200) { - sanitized[key] = `${value.substring(0, 200)}…`; - continue; + if (additionalInfo) { + lines.push(additionalInfo); } - sanitized[key] = value; + await this.email.sendEmail({ + to: ADMIN_EMAIL, + from: ADMIN_EMAIL, + subject, + text: lines.join("\n"), + }); + + this.logger.log("Sent API results notification email", { + subject, + to: ADMIN_EMAIL, + callCount: apiCalls.length, + }); + } catch (err) { + this.logger.warn("Failed to send API results notification email", { + subject, + error: extractErrorMessage(err), + }); } - return sanitized; } + // ============================================================================ + // Customer Notifications + // ============================================================================ + + /** + * Send customer notification email + */ + async sendCustomerEmail(to: string, subject: string, body: string): Promise { + try { + await this.email.sendEmail({ + to, + from: ADMIN_EMAIL, + subject, + text: body, + }); + + this.logger.log("Sent customer notification email", { + subject, + to, + }); + } catch (err) { + this.logger.warn("Failed to send customer notification email", { + subject, + to, + error: extractErrorMessage(err), + }); + } + } + + // ============================================================================ + // Email Body Builders + // ============================================================================ + + /** + * Build eSIM reissue customer email body + */ + buildEsimReissueEmail(customerName: string, simNumber: string, newEid: string): string { + return `Dear ${customerName}, + +This is to confirm that your request to re-issue the SIM card ${simNumber} +to the EID=${newEid} has been accepted. + +Please download the SIM plan, then follow the instructions to install the APN profile. + +eSIM plan download: https://www.asolutions.co.jp/uploads/pdf/esim.pdf +APN profile instructions: https://www.asolutions.co.jp/sim-card/ + +With best regards, +Assist Solutions Customer Support +TEL: 0120-660-470 (Mon-Fri / 10AM-6PM) +Email: ${ADMIN_EMAIL}`; + } + + /** + * Build physical SIM reissue customer email body + */ + buildPhysicalSimReissueEmail(customerName: string, simNumber: string): string { + return `Dear ${customerName}, + +This is to confirm that your request to re-issue the SIM card ${simNumber} +as a physical SIM has been accepted. + +You will be contacted by us again as soon as details about the shipping +schedule can be disclosed (typically in 3-5 business days). + +With best regards, +Assist Solutions Customer Support +TEL: 0120-660-470 (Mon-Fri / 10AM-6PM) +Email: ${ADMIN_EMAIL}`; + } + + /** + * Build cancellation notification email body for admin + */ + buildCancellationAdminEmail(params: { + customerName: string; + simNumber: string; + serialNumber?: string; + cancellationMonth: string; + registeredEmail: string; + comments?: string; + }): string { + return `The following SONIXNET SIM cancellation has been requested. + +Customer name: ${params.customerName} +SIM #: ${params.simNumber} +Serial #: ${params.serialNumber || "N/A"} +Cancellation month: ${params.cancellationMonth} +Registered email address: ${params.registeredEmail} +Comments: ${params.comments || "N/A"}`; + } + + // ============================================================================ + // Error Message Utilities + // ============================================================================ + /** * Convert technical errors to user-friendly messages for SIM operations */ @@ -116,4 +281,65 @@ export class SimNotificationService { // Default fallback return "SIM operation failed. Please try again or contact support."; } + + // ============================================================================ + // Action Runner (for wrapping operations with notifications) + // ============================================================================ + + /** + * Run an operation with automatic success/error notifications. + * Replaces the separate SimActionRunnerService. + */ + async runWithNotification( + action: string, + options: { + baseContext: SimNotificationContext; + enrichSuccess?: (result: T) => Partial; + enrichError?: (error: unknown) => Partial; + }, + handler: () => Promise + ): Promise { + try { + const result = await handler(); + const successContext = { + ...options.baseContext, + ...(options.enrichSuccess ? options.enrichSuccess(result) : {}), + }; + await this.notifySimAction(action, "SUCCESS", successContext); + return result; + } catch (error) { + const errorContext = { + ...options.baseContext, + error: extractErrorMessage(error), + ...(options.enrichError ? options.enrichError(error) : {}), + }; + await this.notifySimAction(action, "ERROR", errorContext); + throw error; + } + } + + // ============================================================================ + // Private Helpers + // ============================================================================ + + /** + * Redact sensitive information from notification context + */ + private redactSensitiveFields(context: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(context)) { + if (typeof key === "string" && key.toLowerCase().includes("password")) { + sanitized[key] = "[REDACTED]"; + continue; + } + + if (typeof value === "string" && value.length > 200) { + sanitized[key] = `${value.substring(0, 200)}…`; + continue; + } + + sanitized[key] = value; + } + return sanitized; + } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts index d95d763b..b920d86c 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts @@ -1,7 +1,7 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; -import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; +import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js"; import { SimValidationService } from "./sim-validation.service.js"; import type { SimPlanChangeRequest, @@ -10,10 +10,9 @@ import type { SimAvailablePlan, } from "@customer-portal/domain/sim"; import { SimScheduleService } from "./sim-schedule.service.js"; -import { SimActionRunnerService } from "./sim-action-runner.service.js"; import { SimManagementQueueService } from "../queue/sim-management.queue.js"; -import { SimApiNotificationService } from "./sim-api-notification.service.js"; -import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js"; +import { SimNotificationService } from "./sim-notification.service.js"; +import { SimServicesService } from "@bff/modules/services/application/sim-services.service.js"; // Mapping from Salesforce SKU to Freebit plan code const SKU_TO_FREEBIT_PLAN_CODE: Record = { @@ -36,12 +35,11 @@ const FREEBIT_PLAN_CODE_TO_SKU: Record = Object.fromEntries( @Injectable() export class SimPlanService { constructor( - private readonly freebitService: FreebitOrchestratorService, + private readonly freebitService: FreebitOperationsService, private readonly simValidation: SimValidationService, private readonly simSchedule: SimScheduleService, - private readonly simActionRunner: SimActionRunnerService, private readonly simQueue: SimManagementQueueService, - private readonly apiNotification: SimApiNotificationService, + private readonly simNotification: SimNotificationService, private readonly simCatalog: SimServicesService, private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger @@ -112,7 +110,7 @@ export class SimPlanService { let account = ""; const assignGlobalIp = request.assignGlobalIp ?? false; - const response = await this.simActionRunner.run( + const response = await this.simNotification.runWithNotification( "Change Plan", { baseContext: { @@ -226,7 +224,7 @@ export class SimPlanService { }); // Send API results email - await this.apiNotification.sendApiResultsEmail( + await this.simNotification.sendApiResultsEmail( "API results - Plan Change", [ { @@ -265,7 +263,7 @@ export class SimPlanService { ): Promise { let account = ""; - await this.simActionRunner.run( + await this.simNotification.runWithNotification( "Update Features", { baseContext: { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts index 6239236b..92222612 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts @@ -1,25 +1,23 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; -import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; +import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SimValidationService } from "./sim-validation.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import type { SimTopUpRequest } from "@customer-portal/domain/sim"; import { SimBillingService } from "./sim-billing.service.js"; -import { SimActionRunnerService } from "./sim-action-runner.service.js"; -import { SimApiNotificationService } from "./sim-api-notification.service.js"; +import { SimNotificationService } from "./sim-notification.service.js"; import { SimTopUpPricingService } from "./sim-topup-pricing.service.js"; @Injectable() export class SimTopUpService { constructor( - private readonly freebitService: FreebitOrchestratorService, + private readonly freebitService: FreebitOperationsService, private readonly mappingsService: MappingsService, private readonly simValidation: SimValidationService, private readonly simBilling: SimBillingService, - private readonly simActionRunner: SimActionRunnerService, - private readonly apiNotification: SimApiNotificationService, + private readonly simNotification: SimNotificationService, private readonly configService: ConfigService, private readonly simTopUpPricing: SimTopUpPricingService, @Inject(Logger) private readonly logger: Logger @@ -43,7 +41,7 @@ export class SimTopUpService { async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise { let latestAccount = ""; - await this.simActionRunner.run( + await this.simNotification.runWithNotification( "Top Up Data", { baseContext: { @@ -143,7 +141,7 @@ export class SimTopUpService { .toISOString() .split("T")[0]; - await this.apiNotification.sendApiResultsEmail("API results", [ + await this.simNotification.sendApiResultsEmail("API results", [ { url: this.whmcsBaseUrl, senddata: { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts index 575538f5..f4478420 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts @@ -1,6 +1,6 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; +import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js"; import { SimValidationService } from "./sim-validation.service.js"; import { SimUsageStoreService } from "../../sim-usage-store.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -11,7 +11,7 @@ import { SimScheduleService } from "./sim-schedule.service.js"; @Injectable() export class SimUsageService { constructor( - private readonly freebitService: FreebitOrchestratorService, + private readonly freebitService: FreebitOperationsService, private readonly simValidation: SimValidationService, private readonly usageStore: SimUsageStoreService, private readonly simSchedule: SimScheduleService, 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 890bdef6..9f408550 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 @@ -22,13 +22,13 @@ import { SimCancellationService } from "./services/sim-cancellation.service.js"; import { EsimManagementService } from "./services/esim-management.service.js"; import { SimValidationService } from "./services/sim-validation.service.js"; import { SimNotificationService } from "./services/sim-notification.service.js"; -import { SimApiNotificationService } from "./services/sim-api-notification.service.js"; import { SimBillingService } from "./services/sim-billing.service.js"; 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 { SimCallHistoryService } from "./services/sim-call-history.service.js"; +import { SimCallHistoryParserService } from "./services/sim-call-history-parser.service.js"; +import { SimCallHistoryFormatterService } from "./services/sim-call-history-formatter.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"; @@ -58,7 +58,6 @@ import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-opti // SIM management services SimValidationService, SimNotificationService, - SimApiNotificationService, SimDetailsService, SimUsageService, SimTopUpService, @@ -69,9 +68,11 @@ import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-opti SimOrchestratorService, SimBillingService, SimScheduleService, - SimActionRunnerService, SimManagementQueueService, SimManagementProcessor, + // Call history services (split for maintainability) + SimCallHistoryParserService, + SimCallHistoryFormatterService, SimCallHistoryService, // Backwards compatibility alias: SimVoiceOptionsService -> VoiceOptionsService { @@ -91,10 +92,8 @@ import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-opti EsimManagementService, SimValidationService, SimNotificationService, - SimApiNotificationService, SimBillingService, SimScheduleService, - SimActionRunnerService, SimManagementQueueService, SimCallHistoryService, // VoiceOptionsService is exported from VoiceOptionsModule diff --git a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts index 95e7261a..a0465958 100644 --- a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts @@ -1,6 +1,6 @@ import { Injectable, BadRequestException, Inject, ConflictException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; +import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js"; import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { CacheService } from "@bff/infra/cache/cache.service.js"; @@ -13,7 +13,7 @@ import { SimScheduleService } from "./sim-management/services/sim-schedule.servi @Injectable() export class SimOrderActivationService { constructor( - private readonly freebit: FreebitOrchestratorService, + private readonly freebit: FreebitOperationsService, private readonly whmcsOrderService: WhmcsOrderService, private readonly mappings: MappingsService, private readonly cache: CacheService, diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts index b156befb..f93fefa9 100644 --- a/apps/bff/src/modules/verification/residence-card.service.ts +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -4,7 +4,7 @@ import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; -import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js"; +import { ServicesCacheService } from "@bff/modules/services/application/services-cache.service.js"; import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; import { assertSalesforceId, diff --git a/apps/portal/package.json b/apps/portal/package.json index dedc24d7..3ad3e018 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -23,6 +23,7 @@ "@tanstack/react-query": "^5.90.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "geist": "^1.5.1", "lucide-react": "^0.562.0", "next": "16.1.1", "react": "19.2.3", diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index 5c838804..90a14dc2 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -9,48 +9,58 @@ /* ============================================================================= DESIGN TOKENS - + Only define CSS variables here. Tailwind @theme maps them to utility classes. ============================================================================= */ :root { --radius: 0.625rem; - /* Core */ - --background: oklch(1 0 0); - --foreground: oklch(0.16 0 0); + /* Typography */ + --font-sans: var(--font-geist-sans, system-ui, sans-serif); + --font-display: var(--font-plus-jakarta-sans, var(--font-sans)); + + /* Core Surfaces */ + --background: oklch(0.995 0 0); + --foreground: oklch(0.13 0.02 265); --card: oklch(1 0 0); --card-foreground: var(--foreground); --popover: oklch(1 0 0); --popover-foreground: var(--foreground); - --muted: oklch(0.96 0.008 234.4); - --muted-foreground: oklch(0.5 0 0); + --muted: oklch(0.97 0.006 265); + --muted-foreground: oklch(0.45 0.02 265); - /* Brand */ - --primary: oklch(0.6884 0.1342 234.4); + /* Brand - Premium Blue (shifted slightly purple for richness) */ + --primary: oklch(0.55 0.18 260); + --primary-hover: oklch(0.48 0.19 260); + --primary-soft: oklch(0.95 0.03 260); --primary-foreground: oklch(0.99 0 0); - --secondary: oklch(0.95 0.015 234.4); - --secondary-foreground: oklch(0.29 0 0); - --accent: oklch(0.95 0.04 234.4); + + /* Gradient Accent - Purple for gradient mixing */ + --accent-gradient: oklch(0.58 0.2 290); + + --secondary: oklch(0.96 0.01 265); + --secondary-foreground: oklch(0.25 0.02 265); + --accent: oklch(0.95 0.04 260); --accent-foreground: var(--foreground); /* 5 Semantic Colors (each: base, foreground, bg, border) */ - --success: oklch(0.42 0.1 145); + --success: oklch(0.52 0.14 155); --success-foreground: oklch(0.99 0 0); --success-bg: oklch(0.98 0.02 145); --success-border: oklch(0.93 0.08 150); - --info: oklch(0.48 0.1 234.4); + --info: oklch(0.55 0.16 230); --info-foreground: oklch(0.99 0 0); --info-bg: oklch(0.97 0.02 234.4); --info-border: oklch(0.91 0.05 234.4); - --warning: oklch(0.45 0.12 55); + --warning: oklch(0.72 0.15 65); --warning-foreground: oklch(0.99 0 0); --warning-bg: oklch(0.99 0.02 90); --warning-border: oklch(0.92 0.12 90); - --danger: oklch(0.42 0.15 12); + --danger: oklch(0.55 0.2 25); --danger-foreground: oklch(0.99 0 0); --danger-bg: oklch(0.98 0.01 10); --danger-border: oklch(0.89 0.06 10); @@ -61,14 +71,15 @@ --neutral-border: oklch(0.87 0.02 272.34); /* Chrome */ - --border: oklch(0.9 0.005 234.4); - --input: oklch(0.88 0.005 234.4); - --ring: oklch(0.72 0.12 234.4); + --border: oklch(0.92 0.005 265); + --input: oklch(0.96 0.004 265); + --ring: oklch(0.55 0.18 260 / 0.5); - /* Sidebar */ - --sidebar: oklch(0.2754 0.1199 272.34); - --sidebar-foreground: oklch(1 0 0); - --sidebar-border: oklch(0.36 0.1 272.34); + /* Sidebar - Deep rich purple-blue */ + --sidebar: oklch(0.18 0.08 280); + --sidebar-foreground: oklch(0.98 0 0); + --sidebar-border: oklch(0.28 0.06 280); + --sidebar-active: oklch(0.99 0 0 / 0.15); /* Header */ --header: oklch(1 0 0 / 0.95); @@ -81,21 +92,50 @@ --chart-3: oklch(0.75 0.14 85); --chart-4: var(--danger); --chart-5: var(--neutral); + + /* Glass Morphism Tokens */ + --glass-bg: oklch(1 0 0 / 0.7); + --glass-bg-strong: oklch(1 0 0 / 0.85); + --glass-border: oklch(1 0 0 / 0.2); + --glass-blur: 12px; + --glass-blur-strong: 20px; + + /* Gradient Presets */ + --gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--accent-gradient) 100%); + --gradient-premium: linear-gradient( + 135deg, + oklch(0.55 0.18 260), + oklch(0.52 0.2 290), + oklch(0.55 0.15 320) + ); + --gradient-subtle: linear-gradient(180deg, oklch(0.99 0.005 260) 0%, oklch(0.97 0.008 290) 100%); + --gradient-glow: radial-gradient(circle at 50% 0%, oklch(0.55 0.18 260 / 0.15), transparent 50%); + + /* Premium Shadows with Color */ + --shadow-primary-sm: 0 2px 8px -2px oklch(0.55 0.18 260 / 0.2); + --shadow-primary-md: 0 4px 16px -4px oklch(0.55 0.18 260 / 0.25); + --shadow-primary-lg: 0 8px 32px -8px oklch(0.55 0.18 260 / 0.3); } .dark { - --background: oklch(0.15 0.01 272.34); - --foreground: oklch(0.98 0 0); - --card: oklch(0.18 0.01 272.34); - --popover: oklch(0.18 0.01 272.34); - --muted: oklch(0.25 0.01 272.34); + /* Surfaces - Rich dark with blue undertone */ + --background: oklch(0.12 0.015 280); + --foreground: oklch(0.95 0 0); + --card: oklch(0.15 0.015 280); + --card-foreground: var(--foreground); + --popover: oklch(0.15 0.015 280); + --popover-foreground: var(--foreground); + --muted: oklch(0.25 0.01 280); --muted-foreground: oklch(0.74 0 0); - --primary: oklch(0.76 0.12 234.4); - --primary-foreground: oklch(0.15 0 0); - --secondary: oklch(0.22 0.01 272.34); + /* Brand - Brighter for dark mode contrast */ + --primary: oklch(0.68 0.16 260); + --primary-hover: oklch(0.72 0.14 260); + --primary-soft: oklch(0.22 0.04 260); + + --secondary: oklch(0.22 0.01 280); --secondary-foreground: oklch(0.9 0 0); - --accent: oklch(0.24 0.03 234.4); + --accent: oklch(0.24 0.03 260); --accent-foreground: oklch(0.92 0 0); --success: oklch(0.72 0.1 145); @@ -123,17 +163,31 @@ --neutral-bg: oklch(0.24 0.02 272.34); --neutral-border: oklch(0.38 0.03 272.34); - --border: oklch(0.32 0.02 272.34); - --input: oklch(0.35 0.02 272.34); - --ring: oklch(0.78 0.11 234.4); + --border: oklch(0.32 0.02 280); + --input: oklch(0.35 0.02 280); + --ring: oklch(0.68 0.16 260 / 0.5); - --sidebar: oklch(0.22 0.1199 272.34); - --sidebar-border: oklch(0.3 0.08 272.34); + /* Sidebar - Slightly lighter than background */ + --sidebar: oklch(0.14 0.02 280); + --sidebar-border: oklch(0.22 0.03 280); - --header: oklch(0.18 0 0 / 0.95); + --header: oklch(0.15 0.015 280 / 0.95); --header-foreground: var(--foreground); --chart-3: oklch(0.82 0.14 85); + + /* Glass for dark mode */ + --glass-bg: oklch(0.15 0.02 280 / 0.6); + --glass-bg-strong: oklch(0.18 0.02 280 / 0.8); + --glass-border: oklch(1 0 0 / 0.1); + + /* Gradients adjusted for dark */ + --gradient-subtle: linear-gradient(180deg, oklch(0.15 0.02 260) 0%, oklch(0.13 0.025 290) 100%); + + /* Shadows for dark mode */ + --shadow-primary-sm: 0 2px 8px -2px oklch(0 0 0 / 0.4); + --shadow-primary-md: 0 4px 16px -4px oklch(0 0 0 / 0.5); + --shadow-primary-lg: 0 8px 32px -8px oklch(0 0 0 / 0.6); } /* ============================================================================= @@ -141,6 +195,11 @@ ============================================================================= */ @theme { + /* Font Families */ + --font-family-sans: var(--font-sans); + --font-family-display: var(--font-display); + + /* Colors */ --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -151,11 +210,14 @@ --color-muted-foreground: var(--muted-foreground); --color-primary: var(--primary); + --color-primary-hover: var(--primary-hover); + --color-primary-soft: var(--primary-soft); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); + --color-accent-gradient: var(--accent-gradient); --color-success: var(--success); --color-success-foreground: var(--success-foreground); @@ -196,6 +258,7 @@ --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-border: var(--sidebar-border); + --color-sidebar-active: var(--sidebar-active); --color-header: var(--header); --color-header-foreground: var(--header-foreground); @@ -206,6 +269,10 @@ --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + + /* Glass tokens */ + --color-glass-bg: var(--glass-bg); + --color-glass-border: var(--glass-border); } @layer base { @@ -214,6 +281,6 @@ } body { @apply bg-background text-foreground; - font-family: var(--font-geist-sans, system-ui), system-ui, sans-serif; + font-family: var(--font-sans); } } diff --git a/apps/portal/src/app/layout.tsx b/apps/portal/src/app/layout.tsx index 01601e36..29c96c14 100644 --- a/apps/portal/src/app/layout.tsx +++ b/apps/portal/src/app/layout.tsx @@ -1,9 +1,19 @@ import type { Metadata } from "next"; import { headers } from "next/headers"; +import { Plus_Jakarta_Sans } from "next/font/google"; +import { GeistSans } from "geist/font/sans"; import "./globals.css"; import { QueryProvider } from "@/core/providers"; import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning"; +// Display font for headlines and hero text +const jakarta = Plus_Jakarta_Sans({ + subsets: ["latin"], + variable: "--font-display", + display: "swap", + weight: ["500", "600", "700", "800"], +}); + export const metadata: Metadata = { title: "Assist Solutions Portal", description: "Manage your subscriptions, billing, and support with Assist Solutions", @@ -24,7 +34,7 @@ export default async function RootLayout({ return ( - + {children} diff --git a/apps/portal/src/components/atoms/AnimatedContainer.tsx b/apps/portal/src/components/atoms/AnimatedContainer.tsx new file mode 100644 index 00000000..8b16dd47 --- /dev/null +++ b/apps/portal/src/components/atoms/AnimatedContainer.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { cn } from "@/shared/utils"; + +interface AnimatedContainerProps { + children: React.ReactNode; + className?: string; + /** Animation type */ + animation?: "fade-up" | "fade-scale" | "slide-left" | "none"; + /** Whether to stagger children animations */ + stagger?: boolean; + /** Delay before animation starts in ms */ + delay?: number; +} + +/** + * Reusable animation wrapper component + * Provides consistent entrance animations for page content + */ +export function AnimatedContainer({ + children, + className, + animation = "fade-up", + stagger = false, + delay = 0, +}: AnimatedContainerProps) { + const animationClass = { + "fade-up": "cp-animate-in", + "fade-scale": "cp-animate-scale-in", + "slide-left": "cp-animate-slide-left", + none: "", + }[animation]; + + return ( +
0 ? { animationDelay: `${delay}ms` } : undefined} + > + {children} +
+ ); +} diff --git a/apps/portal/src/components/atoms/button.tsx b/apps/portal/src/components/atoms/button.tsx index f82a2347..9f7e6444 100644 --- a/apps/portal/src/components/atoms/button.tsx +++ b/apps/portal/src/components/atoms/button.tsx @@ -6,7 +6,7 @@ import { cn } from "@/shared/utils"; import { Spinner } from "./Spinner"; const buttonVariants = cva( - "group inline-flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-[var(--cp-duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50 disabled:pointer-events-none active:scale-[0.98]", + "group inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-[var(--cp-duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50 disabled:pointer-events-none hover:scale-[1.01] active:scale-[0.98]", { variants: { variant: { diff --git a/apps/portal/src/components/atoms/index.ts b/apps/portal/src/components/atoms/index.ts index 6a2c2df0..3d47ffc1 100644 --- a/apps/portal/src/components/atoms/index.ts +++ b/apps/portal/src/components/atoms/index.ts @@ -52,3 +52,6 @@ export { Logo } from "./logo"; // Navigation and Steps export { StepHeader } from "./step-header"; + +// Animation +export { AnimatedContainer } from "./AnimatedContainer"; diff --git a/apps/portal/src/components/atoms/inline-toast.tsx b/apps/portal/src/components/atoms/inline-toast.tsx index 04c81d0d..871dace8 100644 --- a/apps/portal/src/components/atoms/inline-toast.tsx +++ b/apps/portal/src/components/atoms/inline-toast.tsx @@ -1,4 +1,5 @@ import type { HTMLAttributes } from "react"; +import { cn } from "@/shared/utils"; type Tone = "info" | "success" | "warning" | "error"; @@ -15,24 +16,27 @@ export function InlineToast({ className = "", ...rest }: InlineToastProps) { - const toneClasses = - tone === "success" - ? "bg-green-50 border-green-200 text-green-800" - : tone === "warning" - ? "bg-amber-50 border-amber-200 text-amber-800" - : tone === "error" - ? "bg-red-50 border-red-200 text-red-800" - : "bg-blue-50 border-blue-200 text-blue-800"; + const toneClasses = { + success: "bg-success-bg border-success-border text-success", + warning: "bg-warning-bg border-warning-border text-warning", + error: "bg-danger-bg border-danger-border text-danger", + info: "bg-info-bg border-info-border text-info", + }[tone]; return (
{text}
diff --git a/apps/portal/src/components/atoms/input.tsx b/apps/portal/src/components/atoms/input.tsx index 0d8313bd..28d13abe 100644 --- a/apps/portal/src/components/atoms/input.tsx +++ b/apps/portal/src/components/atoms/input.tsx @@ -14,14 +14,15 @@ const Input = forwardRef( ; + return ( +
+ ); } export function LoadingCard({ className }: { className?: string }) { diff --git a/apps/portal/src/features/dashboard/components/ActivityFeed.tsx b/apps/portal/src/features/dashboard/components/ActivityFeed.tsx index 383a3e77..f6ac0a5e 100644 --- a/apps/portal/src/features/dashboard/components/ActivityFeed.tsx +++ b/apps/portal/src/features/dashboard/components/ActivityFeed.tsx @@ -18,6 +18,7 @@ interface ActivityFeedProps { maxItems?: number; isLoading?: boolean; className?: string; + style?: React.CSSProperties; } const ICON_COMPONENTS: Record< @@ -108,16 +109,16 @@ function ActivityItem({ activity, isLast = false }: ActivityItemProps) { function ActivityItemSkeleton({ isLast = false }: { isLast?: boolean }) { return ( -
+
-
+
{!isLast && (
)}
-
-
+
+
); @@ -153,10 +154,11 @@ export function ActivityFeed({ maxItems = 5, isLoading = false, className, + style, }: ActivityFeedProps) { if (isLoading) { return ( -
+

Recent Activity

@@ -172,7 +174,7 @@ export function ActivityFeed({ const visibleActivities = activities.slice(0, maxItems); return ( -
+

Recent Activity

@@ -180,7 +182,7 @@ export function ActivityFeed({ {visibleActivities.length === 0 ? ( ) : ( -
+
{visibleActivities.map((activity, index) => (
@@ -89,13 +90,13 @@ function StatItem({ icon: Icon, label, value, href, tone = "primary", emptyText function StatItemSkeleton() { return ( -
-
+
+
-
-
+
+
-
+
); } @@ -130,7 +131,7 @@ export function QuickStats({

Account Overview

-
+
{/* Greeting skeleton */} -
-
-
-
+
+
+
+
{/* Tasks skeleton */}
-
-
+
+
{/* Bottom section skeleton */}
-
-
+
+
@@ -128,12 +128,25 @@ export function DashboardView() { /> {/* Greeting Section */}
-

Welcome back

-

{displayName}

+

+ Welcome back +

+

+ {displayName} +

{/* Task status badge */} {taskCount > 0 ? ( -
+
) : ( -

Everything is up to date

+

+ Everything is up to date +

)}
{/* Tasks Section - Main focus area */} -
+

Your Tasks

@@ -158,17 +180,24 @@ export function DashboardView() {
{/* Bottom Section: Quick Stats + Recent Activity */} -
+
diff --git a/apps/portal/src/features/landing-page/components/AnimatedBackground.tsx b/apps/portal/src/features/landing-page/components/AnimatedBackground.tsx new file mode 100644 index 00000000..02875623 --- /dev/null +++ b/apps/portal/src/features/landing-page/components/AnimatedBackground.tsx @@ -0,0 +1,70 @@ +import { cn } from "@/shared/utils"; + +interface AnimatedBackgroundProps { + className?: string; +} + +/** + * Mesh gradient background with floating geometric shapes + */ +export function AnimatedBackground({ className }: AnimatedBackgroundProps) { + return ( +
+ {/* Mesh gradient */} +
+ + {/* Floating shapes */} +