From c497eae763c698bc928b3a17872827433f5b9125 Mon Sep 17 00:00:00 2001 From: barsa Date: Fri, 21 Nov 2025 15:59:14 +0900 Subject: [PATCH 1/5] Enhance catalog caching and pricing utilities - Introduced a new interface, LegacyCatalogCachePayload, to improve cache handling in CatalogCacheService, allowing for better normalization of cached values. - Updated the getDisplayPrice function to utilize a centralized price formatting utility, getCatalogProductPriceDisplay, for consistent price rendering across the application. - Refactored order preparation logic in useCheckout to leverage a new domain helper, prepareOrderFromCart, streamlining SKU extraction and payload formatting. - Added CatalogPriceInfo interface to standardize pricing display information across the frontend and backend. --- .../catalog/services/catalog-cache.service.ts | 52 +++++++++++++++++-- .../components/base/EnhancedOrderSummary.tsx | 4 +- .../src/features/catalog/components/index.ts | 2 +- .../src/features/catalog/utils/pricing.ts | 36 +++---------- .../features/checkout/hooks/useCheckout.ts | 27 +++------- packages/domain/catalog/contract.ts | 11 ++++ packages/domain/catalog/utils.ts | 29 +++++++++++ packages/domain/orders/utils.ts | 37 ++++++++++++- 8 files changed, 140 insertions(+), 58 deletions(-) diff --git a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts index 587e8ea8..2877842b 100644 --- a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts +++ b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts @@ -17,6 +17,12 @@ export interface CatalogCacheOptions { ) => CacheDependencies | Promise | undefined; } +interface LegacyCatalogCachePayload { + value: T; + __catalogCache?: boolean; + dependencies?: CacheDependencies; +} + /** * Catalog cache service * @@ -188,13 +194,12 @@ export class CatalogCacheService { const allowNull = options?.allowNull ?? false; // Check Redis cache first - const cached = await this.cache.get(key); + const cached = await this.cache.get>(key); if (cached !== null) { - if (allowNull || cached !== null) { - this.metrics[bucket].hits++; - return cached; - } + const normalized = await this.normalizeCachedValue(key, cached, ttlSeconds); + this.metrics[bucket].hits++; + return normalized; } // Check for in-flight request (prevents thundering herd) @@ -243,6 +248,43 @@ export class CatalogCacheService { return fetchPromise; } + private async normalizeCachedValue( + key: string, + cached: T | LegacyCatalogCachePayload, + ttlSeconds: number | null + ): Promise { + if (this.isLegacyCatalogCachePayload(cached)) { + const ttlArg = ttlSeconds === null ? undefined : ttlSeconds; + const normalizedValue = cached.value; + + if (ttlArg === undefined) { + await this.cache.set(key, normalizedValue); + } else { + await this.cache.set(key, normalizedValue, ttlArg); + } + + if (cached.dependencies) { + await this.storeDependencies(key, cached.dependencies); + await this.linkDependencies(key, cached.dependencies); + } + + return normalizedValue; + } + + return cached; + } + + private isLegacyCatalogCachePayload( + payload: unknown + ): payload is LegacyCatalogCachePayload { + if (!payload || typeof payload !== "object") { + return false; + } + + const record = payload as Record; + return record.__catalogCache === true && Object.prototype.hasOwnProperty.call(record, "value"); + } + /** * Invalidate catalog entries by product IDs * Returns true if any entries were invalidated, false if no matches found diff --git a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx index 2bfeadbc..5dbbe283 100644 --- a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx @@ -21,7 +21,7 @@ export type OrderItem = CatalogProductBase & { itemClass?: string; }; -export interface OrderConfiguration { +export interface OrderSummaryConfiguration { label: string; value: string; important?: boolean; @@ -45,7 +45,7 @@ export interface EnhancedOrderSummaryProps { planDescription?: string; // Configuration details - configurations?: OrderConfiguration[]; + configurations?: OrderSummaryConfiguration[]; // Additional information infoLines?: string[]; diff --git a/apps/portal/src/features/catalog/components/index.ts b/apps/portal/src/features/catalog/components/index.ts index b7fa0154..cc5786d7 100644 --- a/apps/portal/src/features/catalog/components/index.ts +++ b/apps/portal/src/features/catalog/components/index.ts @@ -35,7 +35,7 @@ export type { export type { EnhancedOrderSummaryProps, OrderItem, - OrderConfiguration, + OrderSummaryConfiguration, OrderTotals, } from "./base/EnhancedOrderSummary"; export type { ConfigurationStepProps, StepValidation } from "./base/ConfigurationStep"; diff --git a/apps/portal/src/features/catalog/utils/pricing.ts b/apps/portal/src/features/catalog/utils/pricing.ts index aea8d379..ec9e4e57 100644 --- a/apps/portal/src/features/catalog/utils/pricing.ts +++ b/apps/portal/src/features/catalog/utils/pricing.ts @@ -3,35 +3,15 @@ * These are UI-specific formatting helpers, not business logic */ -import type { CatalogProductBase } from "@customer-portal/domain/catalog"; +import { + getCatalogProductPriceDisplay, + type CatalogProductBase, + type CatalogPriceInfo, +} from "@customer-portal/domain/catalog"; -export interface PriceInfo { - display: string; - monthly: number | null; - oneTime: number | null; - currency: string; -} +// Re-export domain type for compatibility +export type PriceInfo = CatalogPriceInfo; export function getDisplayPrice(item: CatalogProductBase): PriceInfo | null { - const monthlyPrice = item.monthlyPrice ?? null; - const oneTimePrice = item.oneTimePrice ?? null; - const currency = "JPY"; - - if (monthlyPrice === null && oneTimePrice === null) { - return null; - } - - let display = ""; - if (monthlyPrice !== null && monthlyPrice > 0) { - display = `¥${monthlyPrice.toLocaleString()}/month`; - } else if (oneTimePrice !== null && oneTimePrice > 0) { - display = `¥${oneTimePrice.toLocaleString()} (one-time)`; - } - - return { - display, - monthly: monthlyPrice, - oneTime: oneTimePrice, - currency, - }; + return getCatalogProductPriceDisplay(item); } diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index 6f259a2a..0a760a24 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -18,6 +18,7 @@ import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscr import { ORDER_TYPE, orderWithSkuValidationSchema, + prepareOrderFromCart, type CheckoutCart, } from "@customer-portal/domain/orders"; import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service"; @@ -150,31 +151,15 @@ export function useCheckout() { // Debug logging to check cart contents console.log("[DEBUG] Cart data:", cart); console.log("[DEBUG] Cart items:", cart.items); - - const uniqueSkus = Array.from( - new Set( - cart.items - .map(item => item.sku) - .filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0) - ) - ); - - console.log("[DEBUG] Extracted SKUs from cart:", uniqueSkus); - - if (uniqueSkus.length === 0) { - throw new Error("No products selected for order. Please go back and select products."); - } // Validate cart before submission await checkoutService.validateCart(cart); - const orderData = { - orderType, - skus: uniqueSkus, - ...(Object.keys(cart.configuration).length > 0 - ? { configurations: cart.configuration } - : {}), - }; + // Use domain helper to prepare order data + // This encapsulates SKU extraction and payload formatting + const orderData = prepareOrderFromCart(cart, orderType); + + console.log("[DEBUG] Extracted SKUs from cart:", orderData.skus); const currentUserId = useAuthStore.getState().user?.id; if (currentUserId) { diff --git a/packages/domain/catalog/contract.ts b/packages/domain/catalog/contract.ts index c92d7422..0a6d56b6 100644 --- a/packages/domain/catalog/contract.ts +++ b/packages/domain/catalog/contract.ts @@ -50,6 +50,17 @@ export interface PricingTier { originalPrice?: number; } +/** + * Standardized pricing display info + * Used for consistent price rendering across Frontend and BFF + */ +export interface CatalogPriceInfo { + display: string; + monthly: number | null; + oneTime: number | null; + currency: string; +} + /** * Catalog filtering options */ diff --git a/packages/domain/catalog/utils.ts b/packages/domain/catalog/utils.ts index 7bdee0e0..be940102 100644 --- a/packages/domain/catalog/utils.ts +++ b/packages/domain/catalog/utils.ts @@ -8,7 +8,9 @@ import { type SimCatalogCollection, type VpnCatalogCollection, type InternetPlanTemplate, + type CatalogProductBase, } from "./schema"; +import type { CatalogPriceInfo } from "./contract"; /** * Empty catalog defaults shared by portal and BFF. @@ -156,3 +158,30 @@ export function enrichInternetPlanMetadata(plan: InternetPlanCatalogItem): Inter export const internetPlanCollectionSchema = internetPlanCatalogItemSchema.array(); +/** + * Calculates display price information for a catalog item + * Centralized logic for price formatting + */ +export function getCatalogProductPriceDisplay(item: CatalogProductBase): CatalogPriceInfo | null { + const monthlyPrice = item.monthlyPrice ?? null; + const oneTimePrice = item.oneTimePrice ?? null; + const currency = "JPY"; + + if (monthlyPrice === null && oneTimePrice === null) { + return null; + } + + let display = ""; + if (monthlyPrice !== null && monthlyPrice > 0) { + display = `¥${monthlyPrice.toLocaleString()}/month`; + } else if (oneTimePrice !== null && oneTimePrice > 0) { + display = `¥${oneTimePrice.toLocaleString()} (one-time)`; + } + + return { + display, + monthly: monthlyPrice, + oneTime: oneTimePrice, + currency, + }; +} diff --git a/packages/domain/orders/utils.ts b/packages/domain/orders/utils.ts index 271f2d19..6895338b 100644 --- a/packages/domain/orders/utils.ts +++ b/packages/domain/orders/utils.ts @@ -5,7 +5,7 @@ import { type CreateOrderRequest, type OrderSelections, } from "./schema"; -import { ORDER_TYPE } from "./contract"; +import { ORDER_TYPE, type CheckoutCart, type OrderTypeValue } from "./contract"; export function buildOrderConfigurations(selections: OrderSelections): OrderConfigurations { const normalizedSelections = orderSelectionsSchema.parse(selections); @@ -41,3 +41,38 @@ export function createOrderRequest(payload: { }; } +/** + * Transform CheckoutCart into CreateOrderRequest + * Handles SKU extraction, validation, and payload formatting + * + * @throws Error if no products are selected + */ +export function prepareOrderFromCart( + cart: CheckoutCart, + orderType: OrderTypeValue +): CreateOrderRequest { + const uniqueSkus = Array.from( + new Set( + cart.items + .map(item => item.sku) + .filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0) + ) + ); + + if (uniqueSkus.length === 0) { + throw new Error("No products selected for order. Please go back and select products."); + } + + // Note: Zod validation of the final structure should happen at the boundary or via schema.parse + // This function focuses on the structural transformation logic. + + const orderData: CreateOrderRequest = { + orderType, + skus: uniqueSkus, + ...(Object.keys(cart.configuration).length > 0 + ? { configurations: cart.configuration } + : {}), + }; + + return orderData; +} From 46c28969356b84a60aa9f4a73ce67c71066117e3 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 25 Nov 2025 18:31:25 +0900 Subject: [PATCH 2/5] Add endpoint not found error mapping to SecureErrorMapperService - Introduced a new error mapping for "ENDPOINT_NOT_FOUND" with a corresponding code and public message to enhance error handling. - Added a routing pattern to map HTTP method errors to the same "ENDPOINT_NOT_FOUND" code and message for consistent user feedback. --- .../services/secure-error-mapper.service.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/bff/src/core/security/services/secure-error-mapper.service.ts b/apps/bff/src/core/security/services/secure-error-mapper.service.ts index d04ae1d9..67a7918c 100644 --- a/apps/bff/src/core/security/services/secure-error-mapper.service.ts +++ b/apps/bff/src/core/security/services/secure-error-mapper.service.ts @@ -205,6 +205,14 @@ export class SecureErrorMapperService { logLevel: "info", }, ], + [ + "ENDPOINT_NOT_FOUND", + { + code: "VAL_003", + publicMessage: "The requested resource was not found", + logLevel: "info", + }, + ], // Business Logic Errors [ @@ -357,6 +365,16 @@ export class SecureErrorMapperService { }, }, + // HTTP/Routing patterns + { + pattern: /^Cannot\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+/i, + mapping: { + code: "VAL_003", + publicMessage: "The requested resource was not found", + logLevel: "info", + }, + }, + // Validation patterns { pattern: /invalid|required|missing|validation|format/i, From c7230f391a6a54a9b728e9ce6e82b2ba33c7fc96 Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 26 Nov 2025 16:36:06 +0900 Subject: [PATCH 3/5] Refactor global exception handling and support case management - Replaced multiple global exception filters with a unified exception filter to streamline error handling across the application. - Removed deprecated AuthErrorFilter and GlobalExceptionFilter to reduce redundancy. - Enhanced SupportController to include new endpoints for listing, retrieving, and creating support cases, improving the support case management functionality. - Integrated SalesforceCaseService for better interaction with Salesforce data in support case operations. - Updated support case schemas to align with new requirements and ensure data consistency. --- apps/bff/src/app/bootstrap.ts | 11 +- apps/bff/src/core/http/auth-error.filter.ts | 175 ------ apps/bff/src/core/http/exception.filter.ts | 304 +++++++++++ .../src/core/http/http-exception.filter.ts | 102 ---- .../salesforce/salesforce.module.ts | 3 + .../services/salesforce-case.service.ts | 209 ++++++++ .../src/modules/support/support.controller.ts | 28 +- .../bff/src/modules/support/support.module.ts | 3 + .../src/modules/support/support.service.ts | 224 +++++--- .../support/cases/[id]/page.tsx | 11 + .../src/app/(authenticated)/support/page.tsx | 12 + .../molecules/AsyncBlock/AsyncBlock.tsx | 4 +- .../AgentforceWidget/AgentforceWidget.tsx | 152 ++++++ .../organisms/AgentforceWidget/index.ts | 2 + .../components/organisms/AppShell/Header.tsx | 4 +- .../organisms/AppShell/navigation.ts | 1 - apps/portal/src/components/organisms/index.ts | 1 + .../src/features/auth/services/auth.store.ts | 16 +- .../features/catalog/utils/catalog.utils.ts | 7 +- .../dashboard/utils/dashboard.utils.ts | 99 +--- .../features/support/hooks/useCreateCase.ts | 28 + .../features/support/hooks/useSupportCase.ts | 21 + .../support/utils/case-presenters.tsx | 152 ++++++ .../src/features/support/utils/index.ts | 2 + .../support/views/NewSupportCaseView.tsx | 410 +++++++------- .../support/views/SupportCaseDetailView.tsx | 161 ++++++ .../support/views/SupportCasesView.tsx | 500 +++++++----------- .../support/views/SupportHomeView.tsx | 161 ++++++ .../src/features/support/views/index.ts | 2 + apps/portal/src/lib/api/index.ts | 66 ++- apps/portal/src/lib/utils/error-display.ts | 41 -- apps/portal/src/lib/utils/error-handling.ts | 392 +++++++------- apps/portal/src/lib/utils/index.ts | 12 +- packages/domain/catalog/utils.ts | 9 + packages/domain/common/errors.ts | 479 +++++++++++++++++ packages/domain/common/index.ts | 3 +- packages/domain/dashboard/index.ts | 1 + packages/domain/dashboard/utils.ts | 104 ++++ packages/domain/support/contract.ts | 53 +- packages/domain/support/index.ts | 18 + packages/domain/support/providers/index.ts | 18 + .../support/providers/salesforce/index.ts | 7 + .../support/providers/salesforce/mapper.ts | 170 ++++++ .../support/providers/salesforce/raw.types.ts | 322 +++++++++++ packages/domain/support/schema.ts | 55 +- 45 files changed, 3313 insertions(+), 1242 deletions(-) delete mode 100644 apps/bff/src/core/http/auth-error.filter.ts create mode 100644 apps/bff/src/core/http/exception.filter.ts delete mode 100644 apps/bff/src/core/http/http-exception.filter.ts create mode 100644 apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts create mode 100644 apps/portal/src/app/(authenticated)/support/cases/[id]/page.tsx create mode 100644 apps/portal/src/app/(authenticated)/support/page.tsx create mode 100644 apps/portal/src/components/organisms/AgentforceWidget/AgentforceWidget.tsx create mode 100644 apps/portal/src/components/organisms/AgentforceWidget/index.ts create mode 100644 apps/portal/src/features/support/hooks/useCreateCase.ts create mode 100644 apps/portal/src/features/support/hooks/useSupportCase.ts create mode 100644 apps/portal/src/features/support/utils/case-presenters.tsx create mode 100644 apps/portal/src/features/support/utils/index.ts create mode 100644 apps/portal/src/features/support/views/SupportCaseDetailView.tsx create mode 100644 apps/portal/src/features/support/views/SupportHomeView.tsx delete mode 100644 apps/portal/src/lib/utils/error-display.ts create mode 100644 packages/domain/common/errors.ts create mode 100644 packages/domain/dashboard/utils.ts create mode 100644 packages/domain/support/providers/index.ts create mode 100644 packages/domain/support/providers/salesforce/index.ts create mode 100644 packages/domain/support/providers/salesforce/mapper.ts create mode 100644 packages/domain/support/providers/salesforce/raw.types.ts diff --git a/apps/bff/src/app/bootstrap.ts b/apps/bff/src/app/bootstrap.ts index 68dcc9ff..cc969fec 100644 --- a/apps/bff/src/app/bootstrap.ts +++ b/apps/bff/src/app/bootstrap.ts @@ -17,9 +17,7 @@ declare global { } /* eslint-enable @typescript-eslint/no-namespace */ -import { GlobalExceptionFilter } from "../core/http/http-exception.filter"; -import { AuthErrorFilter } from "../core/http/auth-error.filter"; -import { SecureErrorMapperService } from "../core/security/services/secure-error-mapper.service"; +import { UnifiedExceptionFilter } from "../core/http/exception.filter"; import { AppModule } from "../app.module"; @@ -116,11 +114,8 @@ export async function bootstrap(): Promise { maxAge: 86400, // 24 hours }); - // Global exception filters - app.useGlobalFilters( - new AuthErrorFilter(app.get(Logger)), // Handle auth errors first - new GlobalExceptionFilter(app.get(Logger), app.get(SecureErrorMapperService)) // Handle all other errors - ); + // Global exception filter - single unified filter for all errors + app.useGlobalFilters(new UnifiedExceptionFilter(app.get(Logger), app.get(ConfigService))); // Global authentication guard will be registered via APP_GUARD provider in AuthModule diff --git a/apps/bff/src/core/http/auth-error.filter.ts b/apps/bff/src/core/http/auth-error.filter.ts deleted file mode 100644 index fe78bd93..00000000 --- a/apps/bff/src/core/http/auth-error.filter.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { - ExceptionFilter, - Catch, - ArgumentsHost, - UnauthorizedException, - ForbiddenException, - BadRequestException, - ConflictException, - HttpStatus, -} from "@nestjs/common"; -import type { Request, Response } from "express"; -import { Logger } from "nestjs-pino"; - -/** - * Standard error response matching domain apiErrorResponseSchema - */ -interface StandardErrorResponse { - success: false; - error: { - code: string; - message: string; - details?: Record; - }; -} - -@Catch(UnauthorizedException, ForbiddenException, BadRequestException, ConflictException) -export class AuthErrorFilter implements ExceptionFilter { - constructor(private readonly logger: Logger) {} - - catch( - exception: UnauthorizedException | ForbiddenException | BadRequestException | ConflictException, - host: ArgumentsHost - ) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - - const status = exception.getStatus() as HttpStatus; - const responsePayload = exception.getResponse(); - const payloadMessage = - typeof responsePayload === "string" - ? responsePayload - : Array.isArray((responsePayload as { message?: unknown })?.message) - ? (responsePayload as { message: unknown[] }).message.find( - (value): value is string => typeof value === "string" - ) - : (responsePayload as { message?: unknown })?.message; - const exceptionMessage = typeof exception.message === "string" ? exception.message : undefined; - const messageText = - payloadMessage && typeof payloadMessage === "string" - ? payloadMessage - : (exceptionMessage ?? "Authentication error"); - - // Map specific auth errors to user-friendly messages - const userMessage = this.getUserFriendlyMessage(messageText, status); - const errorCode = this.getErrorCode(messageText, status); - - // Log the error (without sensitive information) - const userAgentHeader = request.headers["user-agent"]; - const userAgent = - typeof userAgentHeader === "string" - ? userAgentHeader - : Array.isArray(userAgentHeader) - ? userAgentHeader[0] - : undefined; - this.logger.warn("Authentication error", { - path: request.url, - method: request.method, - errorCode, - userAgent, - ip: request.ip, - }); - - const errorResponse: StandardErrorResponse = { - success: false as const, - error: { - code: errorCode, - message: userMessage, - details: { - timestamp: new Date().toISOString(), - path: request.url, - statusCode: status, - }, - }, - }; - - response.status(status).json(errorResponse); - } - - private getUserFriendlyMessage(message: string, status: HttpStatus): string { - // Production-safe error messages that don't expose sensitive information - if (status === HttpStatus.UNAUTHORIZED) { - if ( - message.includes("Invalid credentials") || - message.includes("Invalid email or password") - ) { - return "Invalid email or password. Please try again."; - } - if (message.includes("Token has been revoked") || message.includes("Invalid refresh token")) { - return "Your session has expired. Please log in again."; - } - if (message.includes("Account is locked")) { - return "Your account has been temporarily locked due to multiple failed login attempts. Please try again later."; - } - if (message.includes("Unable to verify credentials")) { - return "Unable to verify credentials. Please try again later."; - } - if (message.includes("Unable to verify account")) { - return "Unable to verify account. Please try again later."; - } - return "Authentication required. Please log in to continue."; - } - - if (status === HttpStatus.FORBIDDEN) { - if (message.includes("Admin access required")) { - return "You do not have permission to access this resource."; - } - return "Access denied. You do not have permission to perform this action."; - } - - if (status === HttpStatus.BAD_REQUEST) { - if (message.includes("Salesforce account not found")) { - return "Customer account not found. Please contact support."; - } - if (message.includes("Unable to verify customer information")) { - return "Unable to verify customer information. Please contact support."; - } - return "Invalid request. Please check your input and try again."; - } - - if (status === HttpStatus.CONFLICT) { - if (message.includes("already linked")) { - return "This billing account is already linked. Please sign in."; - } - if (message.includes("already exists")) { - return "An account with this email already exists. Please sign in."; - } - return "Conflict detected. Please try again."; - } - - return "Authentication error. Please try again."; - } - - private getErrorCode(message: string, status: HttpStatus): string { - if (status === HttpStatus.UNAUTHORIZED) { - if (message.includes("Invalid credentials") || message.includes("Invalid email or password")) - return "INVALID_CREDENTIALS"; - if (message.includes("Token has been revoked")) return "TOKEN_REVOKED"; - if (message.includes("Invalid refresh token")) return "INVALID_REFRESH_TOKEN"; - if (message.includes("Account is locked")) return "ACCOUNT_LOCKED"; - if (message.includes("Unable to verify credentials")) return "SERVICE_UNAVAILABLE"; - if (message.includes("Unable to verify account")) return "SERVICE_UNAVAILABLE"; - return "UNAUTHORIZED"; - } - - if (status === HttpStatus.FORBIDDEN) { - if (message.includes("Admin access required")) return "ADMIN_REQUIRED"; - return "FORBIDDEN"; - } - - if (status === HttpStatus.BAD_REQUEST) { - if (message.includes("Salesforce account not found")) return "CUSTOMER_NOT_FOUND"; - if (message.includes("Unable to verify customer information")) return "SERVICE_UNAVAILABLE"; - return "INVALID_REQUEST"; - } - - if (status === HttpStatus.CONFLICT) { - if (message.includes("already linked")) return "ACCOUNT_ALREADY_LINKED"; - if (message.includes("already exists")) return "ACCOUNT_EXISTS"; - return "CONFLICT"; - } - - return "AUTH_ERROR"; - } -} diff --git a/apps/bff/src/core/http/exception.filter.ts b/apps/bff/src/core/http/exception.filter.ts new file mode 100644 index 00000000..f5cc6161 --- /dev/null +++ b/apps/bff/src/core/http/exception.filter.ts @@ -0,0 +1,304 @@ +/** + * Unified Exception Filter + * + * Single exception filter that handles all HTTP exceptions consistently. + * Uses the shared error codes and messages from the domain package. + */ + +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Inject, +} from "@nestjs/common"; +import type { Request, Response } from "express"; +import { Logger } from "nestjs-pino"; +import { ConfigService } from "@nestjs/config"; +import { + ErrorCode, + ErrorMessages, + ErrorMetadata, + matchErrorPattern, + type ErrorCodeType, + type ApiError, +} from "@customer-portal/domain/common"; + +/** + * Request context for error logging + */ +interface ErrorContext { + requestId: string; + userId?: string; + method: string; + path: string; + userAgent?: string; + ip?: string; +} + +/** + * Unified exception filter for all HTTP errors. + * Provides consistent error responses and secure logging. + */ +@Catch() +export class UnifiedExceptionFilter implements ExceptionFilter { + private readonly isDevelopment: boolean; + + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly configService: ConfigService + ) { + this.isDevelopment = this.configService.get("NODE_ENV") !== "production"; + } + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + // Build error context for logging + const errorContext = this.buildErrorContext(request); + + // Extract status code and error details + const { status, errorCode, originalMessage } = this.extractErrorDetails(exception); + + // Get user-friendly message (with dev details if in development) + const userMessage = this.getUserMessage(errorCode, originalMessage); + + // Log the error + this.logError(errorCode, originalMessage, status, errorContext, exception); + + // Build and send response + const errorResponse = this.buildErrorResponse(errorCode, userMessage, status, errorContext); + response.status(status).json(errorResponse); + } + + /** + * Extract error details from exception + */ + private extractErrorDetails(exception: unknown): { + status: number; + errorCode: ErrorCodeType; + originalMessage: string; + } { + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let originalMessage = "An unexpected error occurred"; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + originalMessage = this.extractExceptionMessage(exception); + } else if (exception instanceof Error) { + originalMessage = exception.message; + } + + // Map to error code + const errorCode = this.mapToErrorCode(originalMessage, status); + + return { status, errorCode, originalMessage }; + } + + /** + * Extract message from HttpException + */ + private extractExceptionMessage(exception: HttpException): string { + const response = exception.getResponse(); + + if (typeof response === "string") { + return response; + } + + if (typeof response === "object" && response !== null) { + const responseObj = response as Record; + + // Handle NestJS validation errors (array of messages) + if (Array.isArray(responseObj.message)) { + const firstMessage = responseObj.message.find( + (m): m is string => typeof m === "string" + ); + if (firstMessage) return firstMessage; + } + + // Handle standard message field + if (typeof responseObj.message === "string") { + return responseObj.message; + } + + // Handle error field + if (typeof responseObj.error === "string") { + return responseObj.error; + } + } + + return exception.message; + } + + /** + * Map error message and status to error code + */ + private mapToErrorCode(message: string, status: number): ErrorCodeType { + // First, try pattern matching on the message + const patternCode = matchErrorPattern(message); + if (patternCode !== ErrorCode.UNKNOWN) { + return patternCode; + } + + // Fall back to status code mapping + switch (status) { + case HttpStatus.UNAUTHORIZED: + return ErrorCode.SESSION_EXPIRED; + case HttpStatus.FORBIDDEN: + return ErrorCode.FORBIDDEN; + case HttpStatus.NOT_FOUND: + return ErrorCode.NOT_FOUND; + case HttpStatus.CONFLICT: + return ErrorCode.ACCOUNT_EXISTS; + case HttpStatus.BAD_REQUEST: + return ErrorCode.VALIDATION_FAILED; + case HttpStatus.TOO_MANY_REQUESTS: + return ErrorCode.RATE_LIMITED; + case HttpStatus.SERVICE_UNAVAILABLE: + return ErrorCode.SERVICE_UNAVAILABLE; + default: + return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.UNKNOWN; + } + } + + /** + * Get user-friendly message, with dev details in development mode + */ + private getUserMessage(errorCode: ErrorCodeType, originalMessage: string): string { + const userMessage = ErrorMessages[errorCode] ?? ErrorMessages[ErrorCode.UNKNOWN]; + + if (this.isDevelopment && originalMessage !== userMessage) { + // In dev mode, append original message for debugging + const sanitized = this.sanitizeForDev(originalMessage); + if (sanitized && sanitized !== userMessage) { + return `${userMessage} (Dev: ${sanitized})`; + } + } + + return userMessage; + } + + /** + * Sanitize message for development display + */ + private sanitizeForDev(message: string): string { + return message + .replace(/password[=:]\s*[^\s]+/gi, "password=[HIDDEN]") + .replace(/secret[=:]\s*[^\s]+/gi, "secret=[HIDDEN]") + .replace(/token[=:]\s*[^\s]+/gi, "token=[HIDDEN]") + .replace(/key[=:]\s*[^\s]+/gi, "key=[HIDDEN]") + .substring(0, 200); // Limit length + } + + /** + * Build error context from request + */ + private buildErrorContext( + request: Request & { user?: { id?: string }; requestId?: string } + ): ErrorContext { + const userAgentHeader = request.headers["user-agent"]; + + return { + requestId: request.requestId ?? this.generateRequestId(), + userId: request.user?.id, + method: request.method, + path: request.url, + userAgent: + typeof userAgentHeader === "string" + ? userAgentHeader + : Array.isArray(userAgentHeader) + ? userAgentHeader[0] + : undefined, + ip: request.ip, + }; + } + + /** + * Log error with appropriate level based on metadata + */ + private logError( + errorCode: ErrorCodeType, + originalMessage: string, + status: number, + context: ErrorContext, + exception: unknown + ): void { + const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN]; + + const logData = { + errorCode, + category: metadata.category, + severity: metadata.severity, + statusCode: status, + originalMessage: this.sanitizeForLogging(originalMessage), + ...context, + stack: exception instanceof Error ? exception.stack : undefined, + }; + + // Log based on severity + switch (metadata.logLevel) { + case "error": + this.logger.error(`HTTP ${status} Error [${errorCode}]`, logData); + break; + case "warn": + this.logger.warn(`HTTP ${status} Warning [${errorCode}]`, logData); + break; + case "info": + this.logger.log(`HTTP ${status} Info [${errorCode}]`, logData); + break; + case "debug": + this.logger.debug(`HTTP ${status} Debug [${errorCode}]`, logData); + break; + } + } + + /** + * Sanitize message for logging (remove sensitive info) + */ + private sanitizeForLogging(message: string): string { + return message + .replace(/password[=:]\s*[^\s]+/gi, "password=[REDACTED]") + .replace(/secret[=:]\s*[^\s]+/gi, "secret=[REDACTED]") + .replace(/token[=:]\s*[^\s]+/gi, "token=[REDACTED]") + .replace(/\b[A-Za-z0-9]{32,}\b/g, "[TOKEN]") + .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[IP]"); + } + + /** + * Build standard error response + */ + private buildErrorResponse( + errorCode: ErrorCodeType, + message: string, + status: number, + context: ErrorContext + ): ApiError { + const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN]; + + return { + success: false, + error: { + code: errorCode, + message, + details: { + statusCode: status, + category: metadata.category, + timestamp: new Date().toISOString(), + path: context.path, + requestId: context.requestId, + }, + }, + }; + } + + /** + * Generate unique request ID + */ + private generateRequestId(): string { + return `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } +} + diff --git a/apps/bff/src/core/http/http-exception.filter.ts b/apps/bff/src/core/http/http-exception.filter.ts deleted file mode 100644 index 45e1ced7..00000000 --- a/apps/bff/src/core/http/http-exception.filter.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - ExceptionFilter, - Catch, - ArgumentsHost, - HttpException, - HttpStatus, - Inject, -} from "@nestjs/common"; -import type { Request, Response } from "express"; -import { Logger } from "nestjs-pino"; -import { SecureErrorMapperService } from "../security/services/secure-error-mapper.service"; - -@Catch() -export class GlobalExceptionFilter implements ExceptionFilter { - constructor( - @Inject(Logger) private readonly logger: Logger, - private readonly secureErrorMapper: SecureErrorMapperService - ) {} - - catch(exception: unknown, host: ArgumentsHost): void { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - - // Create error context for secure mapping - const errorContext = { - userId: request.user?.id, - requestId: request.requestId || this.generateRequestId(), - userAgent: request.get("user-agent"), - ip: request.ip, - url: request.url, - method: request.method, - }; - - let status: number; - let originalError: unknown = exception; - - // Determine HTTP status - if (exception instanceof HttpException) { - status = exception.getStatus(); - - // Extract the actual error from HttpException response - const exceptionResponse = exception.getResponse(); - if (typeof exceptionResponse === "object" && exceptionResponse !== null) { - const errorResponse = exceptionResponse as { message?: string; error?: string }; - originalError = errorResponse.message || exception.message; - } else { - originalError = - typeof exceptionResponse === "string" ? exceptionResponse : exception.message; - } - } else { - status = HttpStatus.INTERNAL_SERVER_ERROR; - originalError = exception; - } - - // Use secure error mapper to get safe public message and log securely - const errorClassification = this.secureErrorMapper.mapError(originalError, errorContext); - const publicMessage = this.secureErrorMapper.getPublicMessage(originalError, errorContext); - - // Log the error securely (this handles sensitive data filtering) - this.secureErrorMapper.logSecureError(originalError, errorContext, { - httpStatus: status, - exceptionType: exception instanceof Error ? exception.constructor.name : "Unknown", - }); - - // Create secure error response matching domain apiErrorResponseSchema - const errorResponse = { - success: false as const, - error: { - code: errorClassification.mapping.code, - message: publicMessage, - details: { - statusCode: status, - category: errorClassification.category.toUpperCase(), - timestamp: new Date().toISOString(), - path: request.url, - requestId: errorContext.requestId, - }, - }, - }; - - // Additional logging for monitoring (without sensitive data) - this.logger.error(`HTTP ${status} Error [${errorClassification.mapping.code}]`, { - statusCode: status, - method: request.method, - url: request.url, - userAgent: request.get("user-agent"), - ip: request.ip, - errorCode: errorClassification.mapping.code, - category: errorClassification.category, - severity: errorClassification.severity, - requestId: errorContext.requestId, - userId: errorContext.userId, - }); - - response.status(status).json(errorResponse); - } - - private generateRequestId(): string { - return `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - } -} diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index 7e7b5226..e00edd61 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -5,6 +5,7 @@ import { SalesforceService } from "./salesforce.service"; import { SalesforceConnection } from "./services/salesforce-connection.service"; import { SalesforceAccountService } from "./services/salesforce-account.service"; import { SalesforceOrderService } from "./services/salesforce-order.service"; +import { SalesforceCaseService } from "./services/salesforce-case.service"; import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module"; import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard"; import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard"; @@ -15,6 +16,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle SalesforceConnection, SalesforceAccountService, SalesforceOrderService, + SalesforceCaseService, SalesforceService, SalesforceReadThrottleGuard, SalesforceWriteThrottleGuard, @@ -24,6 +26,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle SalesforceService, SalesforceConnection, SalesforceOrderService, + SalesforceCaseService, SalesforceReadThrottleGuard, SalesforceWriteThrottleGuard, ], diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts new file mode 100644 index 00000000..d80d2462 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -0,0 +1,209 @@ +/** + * Salesforce Case Integration Service + * + * Encapsulates all Salesforce Case operations for the portal. + * - Queries cases filtered by Origin = 'Portal Website' + * - Creates cases with portal-specific defaults + * - Validates account ownership for security + * + * Uses domain types and mappers from @customer-portal/domain/support + */ + +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SalesforceConnection } from "./salesforce-connection.service"; +import { assertSalesforceId } from "../utils/soql.util"; +import { getErrorMessage } from "@bff/core/utils/error.util"; +import type { SalesforceResponse } from "@customer-portal/domain/common"; +import { + type SupportCase, + type SalesforceCaseRecord, + type CreateCaseRequest, + PORTAL_CASE_ORIGIN, + SALESFORCE_CASE_ORIGIN, + SALESFORCE_CASE_STATUS, + SALESFORCE_CASE_PRIORITY, + Providers, +} from "@customer-portal/domain/support"; + +// Import the reverse mapping function +const { toSalesforcePriority } = Providers.Salesforce; + +/** + * Parameters for creating a case in Salesforce + * Extends domain CreateCaseRequest with infrastructure-specific fields + */ +export interface CreateCaseParams extends CreateCaseRequest { + /** Salesforce Account ID */ + accountId: string; + /** Optional Salesforce Contact ID */ + contactId?: string; +} + +@Injectable() +export class SalesforceCaseService { + constructor( + private readonly sf: SalesforceConnection, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Get all cases for an account filtered by Portal Website origin + */ + async getCasesForAccount(accountId: string): Promise { + const safeAccountId = assertSalesforceId(accountId, "accountId"); + this.logger.debug({ accountId: safeAccountId }, "Fetching portal cases for account"); + + const soql = Providers.Salesforce.buildCasesForAccountQuery( + safeAccountId, + SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE + ); + + try { + const result = (await this.sf.query(soql, { + label: "support:listCasesForAccount", + })) as SalesforceResponse; + + const cases = result.records || []; + + this.logger.debug( + { accountId: safeAccountId, caseCount: cases.length }, + "Portal cases retrieved for account" + ); + + return Providers.Salesforce.transformSalesforceCasesToSupportCases(cases); + } catch (error: unknown) { + this.logger.error("Failed to fetch cases for account", { + error: getErrorMessage(error), + accountId: safeAccountId, + }); + throw new Error("Failed to fetch support cases"); + } + } + + /** + * Get a single case by ID with account ownership validation + */ + async getCaseById(caseId: string, accountId: string): Promise { + const safeCaseId = assertSalesforceId(caseId, "caseId"); + const safeAccountId = assertSalesforceId(accountId, "accountId"); + + this.logger.debug({ caseId: safeCaseId, accountId: safeAccountId }, "Fetching case by ID"); + + const soql = Providers.Salesforce.buildCaseByIdQuery( + safeCaseId, + safeAccountId, + SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE + ); + + try { + const result = (await this.sf.query(soql, { + label: "support:getCaseById", + })) as SalesforceResponse; + + const record = result.records?.[0]; + + if (!record) { + this.logger.debug({ caseId: safeCaseId }, "Case not found or access denied"); + return null; + } + + return Providers.Salesforce.transformSalesforceCaseToSupportCase(record); + } catch (error: unknown) { + this.logger.error("Failed to fetch case by ID", { + error: getErrorMessage(error), + caseId: safeCaseId, + }); + throw new Error("Failed to fetch support case"); + } + } + + /** + * Create a new case with Portal Website origin + */ + async createCase(params: CreateCaseParams): Promise<{ id: string; caseNumber: string }> { + const safeAccountId = assertSalesforceId(params.accountId, "accountId"); + const safeContactId = params.contactId + ? assertSalesforceId(params.contactId, "contactId") + : undefined; + + this.logger.log( + { accountId: safeAccountId, subject: params.subject }, + "Creating portal support case" + ); + + // Build case payload with portal defaults + // Convert portal display values to Salesforce API values + const sfPriority = params.priority + ? toSalesforcePriority(params.priority) + : SALESFORCE_CASE_PRIORITY.MEDIUM; + + const casePayload: Record = { + Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE, + Status: SALESFORCE_CASE_STATUS.NEW, + Priority: sfPriority, + Subject: params.subject.trim(), + Description: params.description.trim(), + }; + + // Set ContactId if available - Salesforce will auto-populate AccountId from Contact + // If no ContactId, we must set AccountId directly (requires FLS write permission) + if (safeContactId) { + casePayload.ContactId = safeContactId; + } else { + // Only set AccountId when no ContactId is available + // Note: This requires AccountId field-level security write permission + casePayload.AccountId = safeAccountId; + } + + // Note: Category maps to Salesforce Type field + // Only set if Type picklist is configured in Salesforce + // Currently skipped as Type picklist values are unknown + + try { + const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string }; + + if (!created.id) { + throw new Error("Salesforce did not return a case ID"); + } + + // Fetch the created case to get the CaseNumber + const createdCase = await this.getCaseByIdInternal(created.id); + const caseNumber = createdCase?.CaseNumber ?? created.id; + + this.logger.log( + { caseId: created.id, caseNumber }, + "Portal support case created successfully" + ); + + return { id: created.id, caseNumber }; + } catch (error: unknown) { + this.logger.error("Failed to create support case", { + error: getErrorMessage(error), + accountId: safeAccountId, + }); + throw new Error("Failed to create support case"); + } + } + + /** + * Internal method to fetch case without account validation (for post-create lookup) + */ + private async getCaseByIdInternal(caseId: string): Promise { + const safeCaseId = assertSalesforceId(caseId, "caseId"); + + const fields = Providers.Salesforce.buildCaseSelectFields().join(", "); + const soql = ` + SELECT ${fields} + FROM Case + WHERE Id = '${safeCaseId}' + LIMIT 1 + `; + + const result = (await this.sf.query(soql, { + label: "support:getCaseByIdInternal", + })) as SalesforceResponse; + + return result.records?.[0] ?? null; + } +} diff --git a/apps/bff/src/modules/support/support.controller.ts b/apps/bff/src/modules/support/support.controller.ts index bdd07cd3..1c57bde7 100644 --- a/apps/bff/src/modules/support/support.controller.ts +++ b/apps/bff/src/modules/support/support.controller.ts @@ -1,10 +1,14 @@ -import { Controller, Get, Query, Request } from "@nestjs/common"; +import { Controller, Get, Post, Query, Param, Body, Request } from "@nestjs/common"; import { SupportService } from "./support.service"; import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; import { supportCaseFilterSchema, + createCaseRequestSchema, type SupportCaseFilter, type SupportCaseList, + type SupportCase, + type CreateCaseRequest, + type CreateCaseResponse, } from "@customer-portal/domain/support"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; @@ -14,11 +18,27 @@ export class SupportController { @Get("cases") async listCases( - @Request() _req: RequestWithUser, + @Request() req: RequestWithUser, @Query(new ZodValidationPipe(supportCaseFilterSchema)) filters: SupportCaseFilter ): Promise { - void _req; - return this.supportService.listCases(filters); + return this.supportService.listCases(req.user.id, filters); + } + + @Get("cases/:id") + async getCase( + @Request() req: RequestWithUser, + @Param("id") caseId: string + ): Promise { + return this.supportService.getCase(req.user.id, caseId); + } + + @Post("cases") + async createCase( + @Request() req: RequestWithUser, + @Body(new ZodValidationPipe(createCaseRequestSchema)) + body: CreateCaseRequest + ): Promise { + return this.supportService.createCase(req.user.id, body); } } diff --git a/apps/bff/src/modules/support/support.module.ts b/apps/bff/src/modules/support/support.module.ts index f5e78d4a..29c6eb58 100644 --- a/apps/bff/src/modules/support/support.module.ts +++ b/apps/bff/src/modules/support/support.module.ts @@ -1,8 +1,11 @@ import { Module } from "@nestjs/common"; import { SupportController } from "./support.controller"; import { SupportService } from "./support.service"; +import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module"; +import { MappingsModule } from "@bff/modules/id-mappings/mappings.module"; @Module({ + imports: [SalesforceModule, MappingsModule], controllers: [SupportController], providers: [SupportService], exports: [SupportService], diff --git a/apps/bff/src/modules/support/support.service.ts b/apps/bff/src/modules/support/support.service.ts index efb44918..babc127f 100644 --- a/apps/bff/src/modules/support/support.service.ts +++ b/apps/bff/src/modules/support/support.service.ts @@ -1,110 +1,158 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Inject, NotFoundException, ForbiddenException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; import { - SUPPORT_CASE_CATEGORY, SUPPORT_CASE_PRIORITY, SUPPORT_CASE_STATUS, - supportCaseFilterSchema, - supportCaseListSchema, type SupportCase, type SupportCaseFilter, type SupportCaseList, - type SupportCasePriority, - type SupportCaseStatus, + type CreateCaseRequest, + type CreateCaseResponse, } from "@customer-portal/domain/support"; +import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; +import { getErrorMessage } from "@bff/core/utils/error.util"; -const OPEN_STATUSES: SupportCaseStatus[] = [ - SUPPORT_CASE_STATUS.OPEN, +/** + * Status values that indicate an open/active case + * (Display values after mapping from Salesforce Japanese API names) + */ +const OPEN_STATUSES: string[] = [ + SUPPORT_CASE_STATUS.NEW, SUPPORT_CASE_STATUS.IN_PROGRESS, - SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER, + SUPPORT_CASE_STATUS.AWAITING_APPROVAL, ]; -const RESOLVED_STATUSES: SupportCaseStatus[] = [ +/** + * Status values that indicate a resolved/closed case + * (Display values after mapping from Salesforce Japanese API names) + */ +const RESOLVED_STATUSES: string[] = [ + SUPPORT_CASE_STATUS.VPN_PENDING, + SUPPORT_CASE_STATUS.PENDING, SUPPORT_CASE_STATUS.RESOLVED, SUPPORT_CASE_STATUS.CLOSED, ]; -const HIGH_PRIORITIES: SupportCasePriority[] = [ - SUPPORT_CASE_PRIORITY.HIGH, - SUPPORT_CASE_PRIORITY.CRITICAL, -]; +/** + * Priority values that indicate high priority + */ +const HIGH_PRIORITIES: string[] = [SUPPORT_CASE_PRIORITY.HIGH]; @Injectable() export class SupportService { - // Placeholder dataset until Salesforce integration is ready - private readonly cases: SupportCase[] = [ - { - id: 12001, - subject: "VPS Performance Issues", - status: SUPPORT_CASE_STATUS.IN_PROGRESS, - priority: SUPPORT_CASE_PRIORITY.HIGH, - category: SUPPORT_CASE_CATEGORY.TECHNICAL, - createdAt: "2025-08-14T10:30:00Z", - updatedAt: "2025-08-15T14:20:00Z", - lastReply: "2025-08-15T14:20:00Z", - description: "Experiencing slow response times on VPS server, CPU usage appears high.", - assignedTo: "Technical Support Team", - }, - { - id: 12002, - subject: "Billing Question - Invoice #12345", - status: SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER, - priority: SUPPORT_CASE_PRIORITY.MEDIUM, - category: SUPPORT_CASE_CATEGORY.BILLING, - createdAt: "2025-08-13T16:45:00Z", - updatedAt: "2025-08-14T09:30:00Z", - lastReply: "2025-08-14T09:30:00Z", - description: "Need clarification on charges in recent invoice.", - assignedTo: "Billing Department", - }, - { - id: 12003, - subject: "SSL Certificate Installation", - status: SUPPORT_CASE_STATUS.RESOLVED, - priority: SUPPORT_CASE_PRIORITY.LOW, - category: SUPPORT_CASE_CATEGORY.TECHNICAL, - createdAt: "2025-08-12T08:15:00Z", - updatedAt: "2025-08-12T15:45:00Z", - lastReply: "2025-08-12T15:45:00Z", - description: "Request assistance with SSL certificate installation on shared hosting.", - assignedTo: "Technical Support Team", - }, - { - id: 12004, - subject: "Feature Request: Control Panel Enhancement", - status: SUPPORT_CASE_STATUS.OPEN, - priority: SUPPORT_CASE_PRIORITY.LOW, - category: SUPPORT_CASE_CATEGORY.FEATURE_REQUEST, - createdAt: "2025-08-11T13:20:00Z", - updatedAt: "2025-08-11T13:20:00Z", - description: "Would like to see improved backup management in the control panel.", - assignedTo: "Development Team", - }, - { - id: 12005, - subject: "Server Migration Assistance", - status: SUPPORT_CASE_STATUS.CLOSED, - priority: SUPPORT_CASE_PRIORITY.MEDIUM, - category: SUPPORT_CASE_CATEGORY.TECHNICAL, - createdAt: "2025-08-10T11:00:00Z", - updatedAt: "2025-08-11T17:30:00Z", - lastReply: "2025-08-11T17:30:00Z", - description: "Need help migrating website from old server to new VPS.", - assignedTo: "Migration Team", - }, - ]; + constructor( + private readonly caseService: SalesforceCaseService, + private readonly mappingsService: MappingsService, + @Inject(Logger) private readonly logger: Logger + ) {} - async listCases(rawFilters?: SupportCaseFilter): Promise { - const filters = supportCaseFilterSchema.parse(rawFilters ?? {}); - const filteredCases = this.applyFilters(this.cases, filters); - const result = { - cases: filteredCases, - summary: this.buildSummary(filteredCases), - }; - return supportCaseListSchema.parse(result); + /** + * List cases for a user with optional filters + */ + async listCases(userId: string, filters?: SupportCaseFilter): Promise { + const accountId = await this.getAccountIdForUser(userId); + + try { + // SalesforceCaseService now returns SupportCase[] directly using domain mappers + const cases = await this.caseService.getCasesForAccount(accountId); + + const filteredCases = this.applyFilters(cases, filters); + const summary = this.buildSummary(filteredCases); + + return { cases: filteredCases, summary }; + } catch (error) { + this.logger.error("Failed to list support cases", { + userId, + error: getErrorMessage(error), + }); + throw error; + } } - private applyFilters(cases: SupportCase[], filters: SupportCaseFilter): SupportCase[] { + /** + * Get a single case by ID + */ + async getCase(userId: string, caseId: string): Promise { + const accountId = await this.getAccountIdForUser(userId); + + try { + // SalesforceCaseService now returns SupportCase directly using domain mappers + const supportCase = await this.caseService.getCaseById(caseId, accountId); + + if (!supportCase) { + throw new NotFoundException("Support case not found"); + } + + return supportCase; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error("Failed to get support case", { + userId, + caseId, + error: getErrorMessage(error), + }); + throw error; + } + } + + /** + * Create a new support case + */ + async createCase(userId: string, request: CreateCaseRequest): Promise { + const accountId = await this.getAccountIdForUser(userId); + + try { + const result = await this.caseService.createCase({ + subject: request.subject, + description: request.description, + category: request.category, + priority: request.priority, + accountId, + }); + + this.logger.log("Support case created", { + userId, + caseId: result.id, + caseNumber: result.caseNumber, + }); + + return result; + } catch (error) { + this.logger.error("Failed to create support case", { + userId, + error: getErrorMessage(error), + }); + throw error; + } + } + + /** + * Get Salesforce account ID for a user + */ + private async getAccountIdForUser(userId: string): Promise { + const mapping = await this.mappingsService.findByUserId(userId); + + if (!mapping?.sfAccountId) { + this.logger.warn("No Salesforce account mapping found for user", { userId }); + throw new ForbiddenException("Account not linked to Salesforce"); + } + + return mapping.sfAccountId; + } + + /** + * Apply filters to cases + */ + private applyFilters(cases: SupportCase[], filters?: SupportCaseFilter): SupportCase[] { + if (!filters) { + return cases; + } + const search = filters.search?.toLowerCase().trim(); + return cases.filter(supportCase => { if (filters.status && supportCase.status !== filters.status) { return false; @@ -116,7 +164,8 @@ export class SupportService { return false; } if (search) { - const haystack = `${supportCase.subject} ${supportCase.description} ${supportCase.id}`.toLowerCase(); + const haystack = + `${supportCase.subject} ${supportCase.description} ${supportCase.caseNumber}`.toLowerCase(); if (!haystack.includes(search)) { return false; } @@ -125,6 +174,9 @@ export class SupportService { }); } + /** + * Build summary statistics for cases + */ private buildSummary(cases: SupportCase[]): SupportCaseList["summary"] { const open = cases.filter(c => OPEN_STATUSES.includes(c.status)).length; const highPriority = cases.filter(c => HIGH_PRIORITIES.includes(c.priority)).length; diff --git a/apps/portal/src/app/(authenticated)/support/cases/[id]/page.tsx b/apps/portal/src/app/(authenticated)/support/cases/[id]/page.tsx new file mode 100644 index 00000000..0bcc3feb --- /dev/null +++ b/apps/portal/src/app/(authenticated)/support/cases/[id]/page.tsx @@ -0,0 +1,11 @@ +import { SupportCaseDetailView } from "@/features/support"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function SupportCaseDetailPage({ params }: PageProps) { + const { id } = await params; + return ; +} + diff --git a/apps/portal/src/app/(authenticated)/support/page.tsx b/apps/portal/src/app/(authenticated)/support/page.tsx new file mode 100644 index 00000000..5148a44b --- /dev/null +++ b/apps/portal/src/app/(authenticated)/support/page.tsx @@ -0,0 +1,12 @@ +import { SupportHomeView } from "@/features/support"; +import { AgentforceWidget } from "@/components"; + +export default function SupportPage() { + return ( + <> + + + + ); +} + diff --git a/apps/portal/src/components/molecules/AsyncBlock/AsyncBlock.tsx b/apps/portal/src/components/molecules/AsyncBlock/AsyncBlock.tsx index f6bba344..ad69802b 100644 --- a/apps/portal/src/components/molecules/AsyncBlock/AsyncBlock.tsx +++ b/apps/portal/src/components/molecules/AsyncBlock/AsyncBlock.tsx @@ -3,7 +3,7 @@ import React from "react"; import { Skeleton } from "@/components/atoms/loading-skeleton"; import { ErrorState } from "@/components/atoms/error-state"; -import { toUserMessage } from "@/lib/utils"; +import { getErrorMessage } from "@/lib/utils"; interface AsyncBlockProps { isLoading?: boolean; @@ -59,7 +59,7 @@ export function AsyncBlock({ return ( ); diff --git a/apps/portal/src/components/organisms/AgentforceWidget/AgentforceWidget.tsx b/apps/portal/src/components/organisms/AgentforceWidget/AgentforceWidget.tsx new file mode 100644 index 00000000..b2d2d9a6 --- /dev/null +++ b/apps/portal/src/components/organisms/AgentforceWidget/AgentforceWidget.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ChatBubbleLeftRightIcon, XMarkIcon } from "@heroicons/react/24/outline"; + +/** + * Agentforce Widget Configuration + * + * These values should be set in environment variables: + * - NEXT_PUBLIC_SF_EMBEDDED_SERVICE_URL: The Salesforce Messaging for Web script URL + * - NEXT_PUBLIC_SF_EMBEDDED_SERVICE_DEPLOYMENT_ID: The deployment ID + * - NEXT_PUBLIC_SF_ORG_ID: Your Salesforce org ID + * + * To get these values: + * 1. Go to Salesforce Setup > Messaging > Embedded Service Deployments + * 2. Create or select a deployment + * 3. Copy the deployment code snippet + * 4. Extract the URL and deployment ID + */ + +interface AgentforceWidgetProps { + /** + * Whether to show the floating chat button + * If false, the widget will only be triggered programmatically + */ + showFloatingButton?: boolean; +} + +declare global { + interface Window { + embeddedservice_bootstrap?: { + settings: { + language: string; + }; + init: ( + orgId: string, + deploymentName: string, + baseSiteURL: string, + options: { + scrt2URL: string; + } + ) => void; + }; + } +} + +export function AgentforceWidget({ showFloatingButton = true }: AgentforceWidgetProps) { + const [isLoaded, setIsLoaded] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [error, setError] = useState(null); + + // Configuration from environment variables + const scriptUrl = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_URL; + const orgId = process.env.NEXT_PUBLIC_SF_ORG_ID; + const deploymentId = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_DEPLOYMENT_ID; + const baseSiteUrl = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SITE_URL; + const scrt2Url = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SCRT2_URL; + + useEffect(() => { + // Skip if not configured + if (!scriptUrl || !orgId || !deploymentId || !baseSiteUrl || !scrt2Url) { + setError("Agentforce widget is not configured. Please set the required environment variables."); + return; + } + + // Skip if already loaded + if (window.embeddedservice_bootstrap) { + setIsLoaded(true); + return; + } + + // Load the Salesforce Messaging for Web script + const script = document.createElement("script"); + script.src = scriptUrl; + script.async = true; + script.onload = () => { + try { + if (window.embeddedservice_bootstrap) { + window.embeddedservice_bootstrap.settings.language = "en"; + window.embeddedservice_bootstrap.init( + orgId, + deploymentId, + baseSiteUrl, + { scrt2URL: scrt2Url } + ); + setIsLoaded(true); + } + } catch (err) { + setError("Failed to initialize Agentforce widget"); + console.error("Agentforce init error:", err); + } + }; + script.onerror = () => { + setError("Failed to load Agentforce widget script"); + }; + + document.body.appendChild(script); + + return () => { + // Cleanup is handled by Salesforce's script + }; + }, [scriptUrl, orgId, deploymentId, baseSiteUrl, scrt2Url]); + + // If not configured, show nothing or a placeholder + if (error) { + // In development, show the error; in production, fail silently + if (process.env.NODE_ENV === "development") { + return ( +
+

{error}

+
+ ); + } + return null; + } + + // Show custom floating button only if the Salesforce widget hasn't loaded + // Once loaded, Salesforce's native button will appear + if (!isLoaded && showFloatingButton) { + return ( +
+ + + {isOpen && ( +
+
+

AI Assistant

+

Loading...

+
+
+
+
+
+ )} +
+ ); + } + + // Once loaded, Salesforce handles the UI + return null; +} + diff --git a/apps/portal/src/components/organisms/AgentforceWidget/index.ts b/apps/portal/src/components/organisms/AgentforceWidget/index.ts new file mode 100644 index 00000000..55daca7a --- /dev/null +++ b/apps/portal/src/components/organisms/AgentforceWidget/index.ts @@ -0,0 +1,2 @@ +export { AgentforceWidget } from "./AgentforceWidget"; + diff --git a/apps/portal/src/components/organisms/AppShell/Header.tsx b/apps/portal/src/components/organisms/AppShell/Header.tsx index 9373ffb9..47b539df 100644 --- a/apps/portal/src/components/organisms/AppShell/Header.tsx +++ b/apps/portal/src/components/organisms/AppShell/Header.tsx @@ -33,11 +33,11 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
diff --git a/apps/portal/src/components/organisms/AppShell/navigation.ts b/apps/portal/src/components/organisms/AppShell/navigation.ts index d7b6a3c7..a63a4f0c 100644 --- a/apps/portal/src/components/organisms/AppShell/navigation.ts +++ b/apps/portal/src/components/organisms/AppShell/navigation.ts @@ -48,7 +48,6 @@ export const baseNavigation: NavigationItem[] = [ children: [ { name: "Cases", href: "/support/cases" }, { name: "New Case", href: "/support/new" }, - { name: "Knowledge Base", href: "/support/kb" }, ], }, { diff --git a/apps/portal/src/components/organisms/index.ts b/apps/portal/src/components/organisms/index.ts index 54476fc6..d89ae25d 100644 --- a/apps/portal/src/components/organisms/index.ts +++ b/apps/portal/src/components/organisms/index.ts @@ -4,3 +4,4 @@ */ export { AppShell } from "./AppShell/AppShell"; +export { AgentforceWidget } from "./AgentforceWidget"; diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 3d928605..061c7846 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -6,7 +6,7 @@ import { create } from "zustand"; import { apiClient } from "@/lib/api"; import { getNullableData } from "@/lib/api/response-helpers"; -import { getErrorInfo } from "@/lib/utils/error-handling"; +import { parseError } from "@/lib/utils/error-handling"; import { logger } from "@/lib/logger"; import { authResponseSchema, @@ -88,8 +88,8 @@ export const useAuthStore = create()((set, get) => { applyAuthResponse(parsed.data); } catch (error) { logger.error("Failed to refresh session", error); - const errorInfo = getErrorInfo(error); - const reason = logoutReasonFromErrorCode(errorInfo.code) ?? ("session-expired" as const); + const parsed = parseError(error); + const reason = logoutReasonFromErrorCode(parsed.code) ?? ("session-expired" as const); await get().logout({ reason }); throw error; } @@ -141,8 +141,8 @@ export const useAuthStore = create()((set, get) => { } applyAuthResponse(parsed.data, true); // Keep loading for redirect } catch (error) { - const errorInfo = getErrorInfo(error); - set({ loading: false, error: errorInfo.message, isAuthenticated: false }); + const parsed = parseError(error); + set({ loading: false, error: parsed.message, isAuthenticated: false }); throw error; } }, @@ -327,9 +327,9 @@ export const useAuthStore = create()((set, get) => { try { await fetchProfile(); } catch (error) { - const errorInfo = getErrorInfo(error); - if (errorInfo.shouldLogout) { - const reason = logoutReasonFromErrorCode(errorInfo.code) ?? ("session-expired" as const); + const parsed = parseError(error); + if (parsed.shouldLogout) { + const reason = logoutReasonFromErrorCode(parsed.code) ?? ("session-expired" as const); await get().logout({ reason }); return; } diff --git a/apps/portal/src/features/catalog/utils/catalog.utils.ts b/apps/portal/src/features/catalog/utils/catalog.utils.ts index 5d12c5c2..9033a926 100644 --- a/apps/portal/src/features/catalog/utils/catalog.utils.ts +++ b/apps/portal/src/features/catalog/utils/catalog.utils.ts @@ -10,6 +10,7 @@ import type { SimCatalogProduct, VpnCatalogProduct, } from "@customer-portal/domain/catalog"; +import { calculateSavingsPercentage } from "@customer-portal/domain/catalog"; type CatalogProduct = | InternetPlanCatalogItem @@ -49,8 +50,6 @@ export function isProductRecommended(product: CatalogProduct): boolean { /** * Calculate savings percentage (if applicable) + * Re-exported from domain for backward compatibility */ -export function calculateSavings(originalPrice: number, currentPrice: number): number { - if (originalPrice <= currentPrice) return 0; - return Math.round(((originalPrice - currentPrice) / originalPrice) * 100); -} +export const calculateSavings = calculateSavingsPercentage; diff --git a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts index e39671af..74a2216b 100644 --- a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts +++ b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts @@ -6,58 +6,26 @@ import { invoiceActivityMetadataSchema, serviceActivityMetadataSchema, - Activity, - ActivityFilter, - ActivityFilterConfig, + type Activity, + // Re-export business logic from domain + ACTIVITY_FILTERS, + filterActivities, + isActivityClickable, + generateDashboardTasks, + type DashboardTask, + type DashboardTaskSummary, } from "@customer-portal/domain/dashboard"; import { formatCurrency as formatCurrencyUtil } from "@customer-portal/domain/toolkit"; -/** - * Activity filter configurations - */ -export const ACTIVITY_FILTERS: ActivityFilterConfig[] = [ - { key: "all", label: "All" }, - { - key: "billing", - label: "Billing", - types: ["invoice_created", "invoice_paid"], - }, - { - key: "orders", - label: "Orders", - types: ["service_activated"], - }, - { - key: "support", - label: "Support", - types: ["case_created", "case_closed"], - }, -]; - -/** - * Filter activities by type - */ -export function filterActivities(activities: Activity[], filter: ActivityFilter): Activity[] { - if (filter === "all") { - return activities; - } - - const filterConfig = ACTIVITY_FILTERS.find(f => f.key === filter); - if (!filterConfig?.types) { - return activities; - } - - return activities.filter(activity => filterConfig.types!.includes(activity.type)); -} - -/** - * Check if an activity is clickable (navigable) - */ -export function isActivityClickable(activity: Activity): boolean { - const clickableTypes: Activity["type"][] = ["invoice_created", "invoice_paid"]; - - return clickableTypes.includes(activity.type) && !!activity.relatedId; -} +// Re-export domain business logic for backward compatibility +export { + ACTIVITY_FILTERS, + filterActivities, + isActivityClickable, + generateDashboardTasks, + type DashboardTask, + type DashboardTaskSummary, +}; /** * Get navigation path for an activity @@ -165,39 +133,6 @@ export function truncateText(text: string, maxLength = 28): string { return text.slice(0, Math.max(0, maxLength - 1)) + "…"; } -/** - * Generate dashboard task suggestions based on summary data - */ -export function generateDashboardTasks(summary: { - nextInvoice?: { id: number } | null; - stats?: { unpaidInvoices?: number; openCases?: number }; -}): Array<{ label: string; href: string }> { - const tasks: Array<{ label: string; href: string }> = []; - - if (summary.nextInvoice) { - tasks.push({ - label: "Pay upcoming invoice", - href: "#attention", - }); - } - - if (summary.stats?.unpaidInvoices && summary.stats.unpaidInvoices > 0) { - tasks.push({ - label: "Review unpaid invoices", - href: "/billing/invoices", - }); - } - - if (summary.stats?.openCases && summary.stats.openCases > 0) { - tasks.push({ - label: "Check support cases", - href: "/support/cases", - }); - } - - return tasks; -} - /** * Calculate dashboard loading progress */ diff --git a/apps/portal/src/features/support/hooks/useCreateCase.ts b/apps/portal/src/features/support/hooks/useCreateCase.ts new file mode 100644 index 00000000..25cb6329 --- /dev/null +++ b/apps/portal/src/features/support/hooks/useCreateCase.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient, queryKeys } from "@/lib/api"; +import type { CreateCaseRequest, CreateCaseResponse } from "@customer-portal/domain/support"; + +export function useCreateCase() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: CreateCaseRequest) => { + const response = await apiClient.POST("/api/support/cases", { + body: data, + }); + + if (!response.data) { + throw new Error("Failed to create support case"); + } + + return response.data; + }, + onSuccess: () => { + // Invalidate cases list to refetch + void queryClient.invalidateQueries({ queryKey: queryKeys.support.cases() }); + }, + }); +} + diff --git a/apps/portal/src/features/support/hooks/useSupportCase.ts b/apps/portal/src/features/support/hooks/useSupportCase.ts new file mode 100644 index 00000000..fcbc4f5c --- /dev/null +++ b/apps/portal/src/features/support/hooks/useSupportCase.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useAuthSession } from "@/features/auth/services/auth.store"; +import { apiClient, getDataOrThrow, queryKeys } from "@/lib/api"; +import type { SupportCase } from "@customer-portal/domain/support"; + +export function useSupportCase(caseId: string | undefined) { + const { isAuthenticated } = useAuthSession(); + + return useQuery({ + queryKey: queryKeys.support.case(caseId ?? ""), + queryFn: async () => { + const response = await apiClient.GET(`/api/support/cases/${caseId}`); + return getDataOrThrow(response, "Failed to load support case"); + }, + enabled: isAuthenticated && !!caseId, + staleTime: 60 * 1000, + }); +} + diff --git a/apps/portal/src/features/support/utils/case-presenters.tsx b/apps/portal/src/features/support/utils/case-presenters.tsx new file mode 100644 index 00000000..9ed765f7 --- /dev/null +++ b/apps/portal/src/features/support/utils/case-presenters.tsx @@ -0,0 +1,152 @@ +import type { ReactNode } from "react"; +import { + CheckCircleIcon, + ClockIcon, + ExclamationTriangleIcon, + ChatBubbleLeftRightIcon, + SparklesIcon, +} from "@heroicons/react/24/outline"; +import { + SUPPORT_CASE_STATUS, + SUPPORT_CASE_PRIORITY, + type SupportCaseStatus, + type SupportCasePriority, +} from "@customer-portal/domain/support"; + +/** + * Status variant types for styling + */ +export type CaseStatusVariant = "success" | "info" | "warning" | "neutral" | "purple"; +export type CasePriorityVariant = "high" | "medium" | "low" | "neutral"; + +/** + * Icon size options + */ +export type IconSize = "sm" | "md"; + +const ICON_SIZE_CLASSES: Record = { + sm: "h-5 w-5", + md: "h-6 w-6", +}; + +/** + * Status to icon mapping + */ +const STATUS_ICON_MAP: Record ReactNode> = { + [SUPPORT_CASE_STATUS.RESOLVED]: (cls) => , + [SUPPORT_CASE_STATUS.CLOSED]: (cls) => , + [SUPPORT_CASE_STATUS.VPN_PENDING]: (cls) => , + [SUPPORT_CASE_STATUS.PENDING]: (cls) => , + [SUPPORT_CASE_STATUS.IN_PROGRESS]: (cls) => , + [SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: (cls) => , + [SUPPORT_CASE_STATUS.NEW]: (cls) => , +}; + +/** + * Status to variant mapping + */ +const STATUS_VARIANT_MAP: Record = { + [SUPPORT_CASE_STATUS.RESOLVED]: "success", + [SUPPORT_CASE_STATUS.CLOSED]: "success", + [SUPPORT_CASE_STATUS.VPN_PENDING]: "success", + [SUPPORT_CASE_STATUS.PENDING]: "success", + [SUPPORT_CASE_STATUS.IN_PROGRESS]: "info", + [SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: "warning", + [SUPPORT_CASE_STATUS.NEW]: "purple", +}; + +/** + * Priority to variant mapping + */ +const PRIORITY_VARIANT_MAP: Record = { + [SUPPORT_CASE_PRIORITY.HIGH]: "high", + [SUPPORT_CASE_PRIORITY.MEDIUM]: "medium", + [SUPPORT_CASE_PRIORITY.LOW]: "low", +}; + +/** + * Tailwind class mappings for status variants + */ +const STATUS_CLASSES: Record = { + success: "text-green-700 bg-green-50", + info: "text-blue-700 bg-blue-50", + warning: "text-amber-700 bg-amber-50", + purple: "text-purple-700 bg-purple-50", + neutral: "text-gray-700 bg-gray-50", +}; + +/** + * Tailwind class mappings for status variants with border + */ +const STATUS_CLASSES_WITH_BORDER: Record = { + success: "text-green-700 bg-green-50 border-green-200", + info: "text-blue-700 bg-blue-50 border-blue-200", + warning: "text-amber-700 bg-amber-50 border-amber-200", + purple: "text-purple-700 bg-purple-50 border-purple-200", + neutral: "text-gray-700 bg-gray-50 border-gray-200", +}; + +/** + * Tailwind class mappings for priority variants + */ +const PRIORITY_CLASSES: Record = { + high: "text-red-700 bg-red-50", + medium: "text-amber-700 bg-amber-50", + low: "text-green-700 bg-green-50", + neutral: "text-gray-700 bg-gray-50", +}; + +/** + * Tailwind class mappings for priority variants with border + */ +const PRIORITY_CLASSES_WITH_BORDER: Record = { + high: "text-red-700 bg-red-50 border-red-200", + medium: "text-amber-700 bg-amber-50 border-amber-200", + low: "text-green-700 bg-green-50 border-green-200", + neutral: "text-gray-700 bg-gray-50 border-gray-200", +}; + +/** + * Get the icon component for a case status + */ +export function getCaseStatusIcon(status: string, size: IconSize = "sm"): ReactNode { + const sizeClass = ICON_SIZE_CLASSES[size]; + const iconFn = STATUS_ICON_MAP[status]; + + if (iconFn) { + return iconFn(sizeClass); + } + + return ; +} + +/** + * Get the variant type for a case status + */ +export function getCaseStatusVariant(status: string): CaseStatusVariant { + return STATUS_VARIANT_MAP[status] ?? "neutral"; +} + +/** + * Get Tailwind classes for a case status + */ +export function getCaseStatusClasses(status: string, withBorder = false): string { + const variant = getCaseStatusVariant(status); + return withBorder ? STATUS_CLASSES_WITH_BORDER[variant] : STATUS_CLASSES[variant]; +} + +/** + * Get the variant type for a case priority + */ +export function getCasePriorityVariant(priority: string): CasePriorityVariant { + return PRIORITY_VARIANT_MAP[priority] ?? "neutral"; +} + +/** + * Get Tailwind classes for a case priority + */ +export function getCasePriorityClasses(priority: string, withBorder = false): string { + const variant = getCasePriorityVariant(priority); + return withBorder ? PRIORITY_CLASSES_WITH_BORDER[variant] : PRIORITY_CLASSES[variant]; +} + diff --git a/apps/portal/src/features/support/utils/index.ts b/apps/portal/src/features/support/utils/index.ts new file mode 100644 index 00000000..9c9de8bf --- /dev/null +++ b/apps/portal/src/features/support/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./case-presenters"; + diff --git a/apps/portal/src/features/support/views/NewSupportCaseView.tsx b/apps/portal/src/features/support/views/NewSupportCaseView.tsx index e4189317..eac048d5 100644 --- a/apps/portal/src/features/support/views/NewSupportCaseView.tsx +++ b/apps/portal/src/features/support/views/NewSupportCaseView.tsx @@ -4,41 +4,47 @@ import { useState, type FormEvent } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { - ArrowLeftIcon, PaperAirplaneIcon, - ExclamationCircleIcon, - InformationCircleIcon, + SparklesIcon, + ChatBubbleLeftRightIcon, } from "@heroicons/react/24/outline"; +import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid"; -import { logger } from "@/lib/logger"; +import { PageLayout } from "@/components/templates/PageLayout"; +import { AnimatedCard } from "@/components/molecules"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { Button } from "@/components/atoms"; +import { useCreateCase } from "@/features/support/hooks/useCreateCase"; +import { + SUPPORT_CASE_PRIORITY, + type SupportCasePriority, +} from "@customer-portal/domain/support"; export function NewSupportCaseView() { const router = useRouter(); - const [isSubmitting, setIsSubmitting] = useState(false); + const createCaseMutation = useCreateCase(); + const [error, setError] = useState(null); const [formData, setFormData] = useState({ subject: "", - category: "Technical", - priority: "Medium", + priority: SUPPORT_CASE_PRIORITY.MEDIUM as SupportCasePriority, description: "", }); - const handleSubmit = (event: FormEvent) => { + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - setIsSubmitting(true); + setError(null); - // Mock submission - would normally send to API - void (async () => { - try { - await new Promise(resolve => setTimeout(resolve, 2000)); + try { + await createCaseMutation.mutateAsync({ + subject: formData.subject.trim(), + description: formData.description.trim(), + priority: formData.priority, + }); - // Redirect to cases list with success message - router.push("/support/cases?created=true"); - } catch (error) { - logger.error("Error creating case", error); - } finally { - setIsSubmitting(false); - } - })(); + router.push("/support/cases?created=true"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create support case"); + } }; const handleInputChange = (field: string, value: string) => { @@ -50,198 +56,194 @@ export function NewSupportCaseView() { const isFormValid = formData.subject.trim() && formData.description.trim(); + const priorityOptions = [ + { value: SUPPORT_CASE_PRIORITY.LOW, label: "Low - General question" }, + { value: SUPPORT_CASE_PRIORITY.MEDIUM, label: "Medium - Issue affecting work" }, + { value: SUPPORT_CASE_PRIORITY.HIGH, label: "High - Urgent / Service disruption" }, + ]; + return ( -
-
- {/* Header */} -
-
- -
- -
-

Create Support Case

-

Get help from our support team

-
-
- - {/* Help Tips */} -
-
+ } + title="Create Support Case" + description="Get help from our support team" + breadcrumbs={[ + { label: "Support", href: "/support" }, + { label: "Create Case" }, + ]} + > + {/* AI Chat Suggestion */} + +
+
- -
-
-

Before creating a case

-
-
    -
  • Check our knowledge base for common solutions
  • -
  • Include relevant error messages or screenshots
  • -
  • Provide detailed steps to reproduce the issue
  • -
  • Mention your service or subscription if applicable
  • -
+
+
+
+

+ Try our AI Assistant first +

+

+ Get instant answers to common questions. If the AI can't help, it will create + a case for you automatically. +

+
+
+ - {/* Form */} -
-
- {/* Subject */} -
- - handleInputChange("subject", event.target.value)} - placeholder="Brief description of your issue" - className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500" - required - /> -
+ {/* Error Message */} + {error && ( + + {error} + + )} - {/* Category and Priority */} -
-
- - -
- -
- - -
-
- - {/* Description */} -
- -