diff --git a/apps/bff/Dockerfile b/apps/bff/Dockerfile index 8a7e499c..65f8c0a2 100644 --- a/apps/bff/Dockerfile +++ b/apps/bff/Dockerfile @@ -106,7 +106,7 @@ COPY --from=builder /app/apps/bff/prisma ./apps/bff/prisma # Generate Prisma client in production environment WORKDIR /app/apps/bff -RUN pnpm dlx prisma generate +RUN pnpm dlx prisma@6.14.0 generate # Strip build toolchain to shrink image RUN apk del --no-cache python3 make g++ pkgconfig && rm -rf /root/.cache /var/cache/apk/* 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/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, 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/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/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/components/LinkWhmcsForm/LinkWhmcsForm.tsx b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx index 12931c7a..b9e95dc1 100644 --- a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx +++ b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx @@ -1,16 +1,14 @@ +/** + * Link WHMCS Form - Account migration form using domain schema + */ + "use client"; import { useCallback } from "react"; import { Button, Input, ErrorMessage } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; import { useWhmcsLink } from "@/features/auth/hooks"; -import { - linkWhmcsRequestSchema, - type LinkWhmcsRequest, - type LinkWhmcsResponse, -} from "@customer-portal/domain/auth"; - -type LinkWhmcsFormData = LinkWhmcsRequest; +import { linkWhmcsRequestSchema, type LinkWhmcsResponse } from "@customer-portal/domain/auth"; import { useZodForm } from "@customer-portal/validation"; interface LinkWhmcsFormProps { @@ -21,80 +19,60 @@ interface LinkWhmcsFormProps { export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) { const { linkWhmcs, loading, error, clearError } = useWhmcsLink(); - const handleLink = useCallback( - async (formData: LinkWhmcsFormData) => { + const form = useZodForm({ + schema: linkWhmcsRequestSchema, + initialValues: { email: "", password: "" }, + onSubmit: async data => { clearError(); - const payload: LinkWhmcsRequest = { - email: formData.email, - password: formData.password, - }; - const result = await linkWhmcs(payload); + const result = await linkWhmcs(data); onTransferred?.(result); }, - [linkWhmcs, onTransferred, clearError] - ); - - const { values, errors, isSubmitting, setValue, handleSubmit } = useZodForm({ - schema: linkWhmcsRequestSchema, - initialValues: { - email: "", - password: "", - }, - onSubmit: handleLink, }); + const isLoading = form.isSubmitting || loading; + return ( -
-
-
-

Link Your WHMCS Account

-

- Enter your existing WHMCS credentials to link your account and migrate your data. -

-
+
void form.handleSubmit(e)} className={`space-y-5 ${className}`}> + + form.setValue("email", e.target.value)} + onBlur={() => form.setTouchedField("email")} + placeholder="you@example.com" + disabled={isLoading} + autoComplete="email" + autoFocus + /> + - void handleSubmit(event)} className="space-y-4"> - - setValue("email", e.target.value)} - placeholder="Enter your WHMCS email" - disabled={isSubmitting || loading} - className="w-full" - /> - + + form.setValue("password", e.target.value)} + onBlur={() => form.setTouchedField("password")} + placeholder="Enter your legacy portal password" + disabled={isLoading} + autoComplete="current-password" + /> + - - setValue("password", e.target.value)} - placeholder="Enter your WHMCS password" - disabled={isSubmitting || loading} - className="w-full" - /> - + {error && {error}} - {error && {error}} + - -
- -
-

- Your credentials are used only to verify your identity and migrate your data securely. -

-
-
-
+

+ Your credentials are encrypted and used only to verify your identity +

+ ); } diff --git a/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx b/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx index 00a27a83..ca6d9326 100644 --- a/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx +++ b/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx @@ -1,6 +1,5 @@ /** - * Set Password Form Component - * Form for setting password after WHMCS account linking - migrated to use Zod validation + * Set Password Form - Password creation after WHMCS migration */ "use client"; @@ -11,148 +10,133 @@ import { Button, Input, ErrorMessage } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; import { useWhmcsLink } from "../../hooks/use-auth"; import { useZodForm } from "@customer-portal/validation"; -import { setPasswordRequestSchema } from "@customer-portal/domain/auth"; +import { + setPasswordRequestSchema, + checkPasswordStrength, + getPasswordStrengthDisplay, +} from "@customer-portal/domain/auth"; import { z } from "zod"; +// Extend domain schema with confirmPassword +const setPasswordFormSchema = setPasswordRequestSchema + .extend({ confirmPassword: z.string().min(1, "Please confirm your password") }) + .refine(data => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + interface SetPasswordFormProps { email?: string; onSuccess?: () => void; onError?: (error: string) => void; - showLoginLink?: boolean; className?: string; } -export function SetPasswordForm({ - email = "", - onSuccess, - onError, - showLoginLink = true, - className = "", -}: SetPasswordFormProps) { +export function SetPasswordForm({ email = "", onSuccess, onError, className = "" }: SetPasswordFormProps) { const { setPassword, loading, error, clearError } = useWhmcsLink(); - /** - * Frontend form schema - extends domain setPasswordRequestSchema with confirmPassword - * - * Single source of truth: Domain layer defines validation rules - * Frontend only adds: confirmPassword field and password matching logic - */ - const setPasswordFormSchema = setPasswordRequestSchema - .extend({ - confirmPassword: z.string().min(1, "Please confirm your password"), - }) - .superRefine((data, ctx) => { - if (data.password !== data.confirmPassword) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["confirmPassword"], - message: "Passwords do not match", - }); - } - }); - - type SetPasswordFormValues = z.infer; - - const form = useZodForm({ + const form = useZodForm({ schema: setPasswordFormSchema, - initialValues: { - email, - password: "", - confirmPassword: "", - }, - onSubmit: async ({ confirmPassword: _ignore, ...data }) => { - void _ignore; + initialValues: { email, password: "", confirmPassword: "" }, + onSubmit: async data => { clearError(); try { await setPassword(data.email, data.password); onSuccess?.(); } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Failed to set password"; - onError?.(errorMessage); + onError?.(err instanceof Error ? err.message : "Failed to set password"); throw err; } }, }); - // Handle errors from auth hooks + const { requirements, strength, isValid } = checkPasswordStrength(form.values.password); + const { label, colorClass } = getPasswordStrengthDisplay(strength); + const passwordsMatch = form.values.password === form.values.confirmPassword; + const isLoading = loading || form.isSubmitting; + const isEmailProvided = Boolean(email); + useEffect(() => { - if (error) { - onError?.(error); - } + if (error) onError?.(error); }, [error, onError]); - // Update email when prop changes useEffect(() => { - if (email && email !== form.values.email) { - form.setValue("email", email); - } + if (email && email !== form.values.email) form.setValue("email", email); }, [email, form]); return ( -
-
-

Set your password

-

- Create a password for your account to complete the setup. -

-
+
void form.handleSubmit(e)} className={`space-y-5 ${className}`}> + + !isEmailProvided && form.setValue("email", e.target.value)} + disabled={isLoading || isEmailProvided} + readOnly={isEmailProvided} + className={isEmailProvided ? "bg-gray-50 text-gray-600" : ""} + /> + {isEmailProvided &&

Verified during account transfer

} +
- void form.handleSubmit(event)} className="space-y-4"> - - form.setValue("email", e.target.value)} - onBlur={() => form.setTouched("email", true)} - disabled={loading || form.isSubmitting} - className={form.errors.email ? "border-red-300" : ""} - /> - + + form.setValue("password", e.target.value)} + onBlur={() => form.setTouched("password", true)} + placeholder="Create a secure password" + disabled={isLoading} + autoComplete="new-password" + autoFocus + /> + - - form.setValue("password", e.target.value)} - onBlur={() => form.setTouched("password", true)} - disabled={loading || form.isSubmitting} - className={form.errors.password ? "border-red-300" : ""} - /> - - - - form.setValue("confirmPassword", e.target.value)} - onBlur={() => form.setTouched("confirmPassword", true)} - disabled={loading || form.isSubmitting} - className={form.errors.confirmPassword ? "border-red-300" : ""} - /> - - - {(error || form.errors._form) && {form.errors._form || error}} - - -
- - {showLoginLink && ( -
- - Back to login - + {form.values.password && ( +
+
+
+
+
+ {label} +
+
+ {requirements.map(r => ( +
+ {r.met ? "✓" : "○"} + {r.label} +
+ ))} +
)} -
+ + + form.setValue("confirmPassword", e.target.value)} + onBlur={() => form.setTouched("confirmPassword", true)} + placeholder="Re-enter your password" + disabled={isLoading} + autoComplete="new-password" + /> + + + {form.values.confirmPassword && ( +

+ {passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"} +

+ )} + + {(error || form.errors._form) && {form.errors._form || error}} + + + +
+ Back to login +
+ ); } diff --git a/apps/portal/src/features/auth/components/SignupForm/AccountStep.tsx b/apps/portal/src/features/auth/components/SignupForm/AccountStep.tsx deleted file mode 100644 index ff66cbec..00000000 --- a/apps/portal/src/features/auth/components/SignupForm/AccountStep.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Account Step Component - * Email and password fields for signup - */ - -"use client"; - -import { FormField } from "@/components/molecules/FormField/FormField"; - -interface AccountStepProps { - formData: { - email: string; - password: string; - confirmPassword: string; - }; - errors: { - email?: string; - password?: string; - confirmPassword?: string; - }; - onFieldChange: (field: string, value: string) => void; - onFieldBlur: (field: string) => void; - loading?: boolean; -} - -export function AccountStep({ - formData, - errors, - onFieldChange, - onFieldBlur, - loading = false, -}: AccountStepProps) { - return ( -
- onFieldChange("email", e.target.value)} - onBlur={() => onFieldBlur("email")} - placeholder="Enter your email address" - disabled={loading} - autoComplete="email" - autoFocus - /> - - onFieldChange("password", e.target.value)} - onBlur={() => onFieldBlur("password")} - placeholder="Create a strong password" - disabled={loading} - autoComplete="new-password" - /> - - onFieldChange("confirmPassword", e.target.value)} - onBlur={() => onFieldBlur("confirmPassword")} - placeholder="Confirm your password" - disabled={loading} - autoComplete="new-password" - /> -
- ); -} diff --git a/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx b/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx deleted file mode 100644 index ae1e459e..00000000 --- a/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Address Step Component - * Address information fields for signup using Zod validation - */ - -"use client"; - -import { useCallback } from "react"; -import { Input } from "@/components/atoms"; -import { FormField } from "@/components/molecules/FormField/FormField"; -import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation"; -import type { SignupFormValues } from "./SignupForm"; -import type { Address } from "@customer-portal/domain/customer"; -import { COUNTRY_OPTIONS } from "@/lib/constants/countries"; - -interface AddressStepProps { - address: SignupFormValues["address"]; - errors: FormErrors; - touched: FormTouched; - onAddressChange: (address: SignupFormValues["address"]) => void; - setTouchedField: UseZodFormReturn["setTouchedField"]; -} - -export function AddressStep({ - address, - errors, - touched, - onAddressChange, - setTouchedField, -}: AddressStepProps) { - // Use domain Address type directly - no type helpers needed - const updateAddressField = useCallback( - (field: keyof Address, value: string) => { - onAddressChange({ ...(address ?? {}), [field]: value }); - }, - [address, onAddressChange] - ); - - const handleCountryChange = useCallback( - (code: string) => { - const normalized = code || ""; - onAddressChange({ - ...(address ?? {}), - country: normalized, - countryCode: normalized, - }); - }, - [address, onAddressChange] - ); - - const getFieldError = useCallback( - (field: keyof Address) => { - const fieldKey = `address.${field}`; - const isTouched = touched[fieldKey] ?? touched.address; - - if (!isTouched) { - return undefined; - } - - return errors[fieldKey] ?? errors[field] ?? errors.address; - }, - [errors, touched] - ); - - const markTouched = useCallback(() => { - setTouchedField("address"); - }, [setTouchedField]); - - return ( -
- - updateAddressField("address1", e.target.value)} - onBlur={markTouched} - placeholder="Enter your street address" - className="w-full" - /> - - - - updateAddressField("address2", e.target.value)} - onBlur={markTouched} - placeholder="Apartment, suite, etc." - className="w-full" - /> - - -
- - updateAddressField("city", e.target.value)} - onBlur={markTouched} - placeholder="Enter your city" - className="w-full" - /> - - - - updateAddressField("state", e.target.value)} - onBlur={markTouched} - placeholder="Enter your state/province" - className="w-full" - /> - -
- -
- - updateAddressField("postcode", e.target.value)} - onBlur={markTouched} - placeholder="Enter your postal code" - className="w-full" - /> - - - - - -
-
- ); -} diff --git a/apps/portal/src/features/auth/components/SignupForm/PasswordStep.tsx b/apps/portal/src/features/auth/components/SignupForm/PasswordStep.tsx deleted file mode 100644 index 8013a19a..00000000 --- a/apps/portal/src/features/auth/components/SignupForm/PasswordStep.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Password Step Component - * Password and security fields for signup using Zod validation - */ - -"use client"; - -import { Input } from "@/components/atoms"; -import { FormField } from "@/components/molecules/FormField/FormField"; -import type { UseZodFormReturn } from "@customer-portal/validation"; -import type { SignupFormValues } from "./SignupForm"; - -type PasswordStepProps = Pick< - UseZodFormReturn, - "values" | "errors" | "touched" | "setValue" | "setTouchedField" ->; - -export function PasswordStep({ - values, - errors, - touched, - setValue, - setTouchedField, -}: PasswordStepProps) { - return ( -
- - setValue("password", e.target.value)} - onBlur={() => setTouchedField("password")} - placeholder="Create a secure password" - className="w-full" - /> - - - - setValue("confirmPassword", e.target.value)} - onBlur={() => setTouchedField("confirmPassword")} - placeholder="Confirm your password" - className="w-full" - /> - - -
-
-
- setValue("acceptTerms", e.target.checked)} - onBlur={() => setTouchedField("acceptTerms")} - className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" - /> -
-
- - {touched.acceptTerms && errors.acceptTerms && ( -

{errors.acceptTerms}

- )} -
-
- -
-
- setValue("marketingConsent", e.target.checked)} - onBlur={() => setTouchedField("marketingConsent")} - className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" - /> -
-
- -
-
-
-
- ); -} diff --git a/apps/portal/src/features/auth/components/SignupForm/PersonalStep.tsx b/apps/portal/src/features/auth/components/SignupForm/PersonalStep.tsx deleted file mode 100644 index 5fa0410d..00000000 --- a/apps/portal/src/features/auth/components/SignupForm/PersonalStep.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Personal Step Component - * Personal information fields for signup using Zod validation - */ - -"use client"; - -import { Input } from "@/components/atoms"; -import { FormField } from "@/components/molecules/FormField/FormField"; -import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation"; -import type { SignupFormValues } from "./SignupForm"; - -interface PersonalStepProps { - values: SignupFormValues; - errors: FormErrors; - touched: FormTouched; - setValue: UseZodFormReturn["setValue"]; - setTouchedField: UseZodFormReturn["setTouchedField"]; -} - -export function PersonalStep({ - values, - errors, - touched, - setValue, - setTouchedField, -}: PersonalStepProps) { - const getError = (field: keyof SignupFormValues) => { - return touched[field as string] ? errors[field as string] : undefined; - }; - - return ( -
-
- - setValue("firstName", e.target.value)} - onBlur={() => setTouchedField("firstName")} - placeholder="Enter your first name" - className="w-full" - /> - - - - setValue("lastName", e.target.value)} - onBlur={() => setTouchedField("lastName")} - placeholder="Enter your last name" - className="w-full" - /> - -
- - - setValue("email", e.target.value)} - onBlur={() => setTouchedField("email")} - placeholder="Enter your email address" - className="w-full" - /> - - - - setValue("phone", e.target.value)} - onBlur={() => setTouchedField("phone")} - placeholder="+81 XX-XXXX-XXXX" - className="w-full" - /> - - - - setValue("sfNumber", e.target.value)} - onBlur={() => setTouchedField("sfNumber")} - placeholder="Enter your customer number" - className="w-full" - /> - - - - setValue("company", e.target.value)} - onBlur={() => setTouchedField("company")} - placeholder="Enter your company name" - className="w-full" - /> - -
- ); -} diff --git a/apps/portal/src/features/auth/components/SignupForm/PreferencesStep.tsx b/apps/portal/src/features/auth/components/SignupForm/PreferencesStep.tsx deleted file mode 100644 index 3ab6c1ac..00000000 --- a/apps/portal/src/features/auth/components/SignupForm/PreferencesStep.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Preferences Step Component - * Terms acceptance and marketing preferences - */ - -"use client"; - -import Link from "next/link"; - -interface PreferencesStepProps { - formData: { - acceptTerms: boolean; - marketingConsent: boolean; - }; - errors: { - acceptTerms?: string; - }; - onFieldChange: (field: string, value: boolean) => void; - onFieldBlur: (field: string) => void; - loading?: boolean; -} - -export function PreferencesStep({ - formData, - errors, - onFieldChange, - onFieldBlur, - loading = false, -}: PreferencesStepProps) { - return ( -
-
-
- onFieldChange("acceptTerms", e.target.checked)} - onBlur={() => onFieldBlur("acceptTerms")} - disabled={loading} - className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500" - /> -
- - {errors.acceptTerms && ( -

{errors.acceptTerms}

- )} -
-
- -
- onFieldChange("marketingConsent", e.target.checked)} - disabled={loading} - className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500" - /> - -
-
- -
-

Almost done!

-

- By clicking "Create Account", you'll be able to access your dashboard and - start using our services immediately. -

-
-
- ); -} diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx index 45d17674..ac1cb4dc 100644 --- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx @@ -1,275 +1,156 @@ /** - * Signup Form Component - * Multi-step signup form using Zod validation + * Signup Form - Multi-step signup using domain schemas */ "use client"; -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback } from "react"; import Link from "next/link"; import { ErrorMessage } from "@/components/atoms"; import { useSignup } from "../../hooks/use-auth"; import { - type SignupRequest, signupInputSchema, buildSignupRequest, } from "@customer-portal/domain/auth"; +import { addressFormSchema } from "@customer-portal/domain/customer"; import { useZodForm } from "@customer-portal/validation"; import { z } from "zod"; -import { MultiStepForm, type FormStep } from "./MultiStepForm"; -import { AddressStep } from "./AddressStep"; -import { PasswordStep } from "./PasswordStep"; -import { PersonalStep } from "./PersonalStep"; +import { MultiStepForm } from "./MultiStepForm"; +import { AccountStep } from "./steps/AccountStep"; +import { AddressStep } from "./steps/AddressStep"; +import { PasswordStep } from "./steps/PasswordStep"; +import { ReviewStep } from "./steps/ReviewStep"; import { getCountryCodeByName } from "@/lib/constants/countries"; -interface SignupFormProps { - onSuccess?: () => void; - onError?: (error: string) => void; - showLoginLink?: boolean; - className?: string; -} - -/** - * Frontend form schema - extends domain signupInputSchema with UI-specific fields - * - * Single source of truth: Domain layer (signupInputSchema) defines all validation rules - * Frontend only adds: confirmPassword field and password matching logic - */ -export const signupFormSchema = signupInputSchema +// Extend domain schema with confirmPassword for frontend +const signupFormSchema = signupInputSchema .extend({ confirmPassword: z.string().min(1, "Please confirm your password"), + address: addressFormSchema, }) .refine(data => data.acceptTerms === true, { message: "You must accept the terms and conditions", path: ["acceptTerms"], }) - .superRefine((data, ctx) => { - if (data.password !== data.confirmPassword) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["confirmPassword"], - message: "Passwords do not match", - }); - } + .refine(data => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], }); -export type SignupFormValues = z.infer; +type SignupFormData = z.infer; -export function SignupForm({ - onSuccess, - onError, - showLoginLink = true, - className = "", -}: SignupFormProps) { +interface SignupFormProps { + onSuccess?: () => void; + onError?: (error: string) => void; + className?: string; +} + +const STEPS = [ + { key: "account", title: "Account Details", description: "Your contact information" }, + { key: "address", title: "Service Address", description: "Where to deliver your SIM" }, + { key: "password", title: "Create Password", description: "Secure your account" }, + { key: "review", title: "Review & Accept", description: "Confirm your details" }, +] as const; + +export function SignupForm({ onSuccess, onError, className = "" }: SignupFormProps) { const { signup, loading, error, clearError } = useSignup(); - const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [step, setStep] = useState(0); - const handleSignup = useCallback( - async ({ confirmPassword: _confirm, ...formData }: SignupFormValues) => { - void _confirm; - clearError(); - try { - const normalizeCountryCode = (value?: string) => { - if (!value) return ""; - if (value.length === 2) return value.toUpperCase(); - return getCountryCodeByName(value) ?? value; - }; - - const normalizedAddress = formData.address - ? (() => { - const countryValue = formData.address.country || formData.address.countryCode || ""; - const normalizedCountry = normalizeCountryCode(countryValue); - return { - ...formData.address, - country: normalizedCountry, - countryCode: normalizedCountry, - }; - })() - : undefined; - - const request: SignupRequest = buildSignupRequest({ - ...formData, - ...(normalizedAddress ? { address: normalizedAddress } : {}), - }); - await signup(request); - onSuccess?.(); - } catch (err) { - const message = err instanceof Error ? err.message : "Signup failed"; - onError?.(message); - throw err; // Re-throw to let useZodForm handle the error state - } - }, - [signup, onSuccess, onError, clearError] - ); - - const { - values, - errors, - touched, - isSubmitting, - setValue, - setTouchedField, - handleSubmit, - validate, - } = useZodForm({ + const form = useZodForm({ schema: signupFormSchema, initialValues: { - email: "", - password: "", - confirmPassword: "", + sfNumber: "", firstName: "", lastName: "", - company: "", + email: "", phone: "", - sfNumber: "", - address: { - address1: "", - address2: "", - city: "", - state: "", - postcode: "", - country: "", - countryCode: "", - }, - nationality: "", - dateOfBirth: "", - gender: "male" as const, + company: "", + address: { address1: "", address2: "", city: "", state: "", postcode: "", country: "", countryCode: "" }, + password: "", + confirmPassword: "", acceptTerms: false, marketingConsent: false, }, - onSubmit: handleSignup, + onSubmit: async data => { + clearError(); + try { + const normalizedAddress = { + ...data.address, + country: getCountryCodeByName(data.address.country) ?? data.address.country, + countryCode: getCountryCodeByName(data.address.countryCode) ?? data.address.countryCode, + }; + const request = buildSignupRequest({ ...data, address: normalizedAddress }); + await signup(request); + onSuccess?.(); + } catch (err) { + onError?.(err instanceof Error ? err.message : "Signup failed"); + throw err; + } + }, }); - // Handle step change with validation - const handleStepChange = useCallback((stepIndex: number) => { - setCurrentStepIndex(stepIndex); - }, []); + const isLastStep = step === STEPS.length - 1; - // Step field definitions (memoized for performance) - const stepFields = useMemo( - () => ({ - 0: ["firstName", "lastName", "email", "phone"] as const, - 1: ["address"] as const, - 2: ["password", "confirmPassword"] as const, - 3: ["sfNumber", "acceptTerms"] as const, - }), - [] - ); + const handleNext = useCallback(() => { + form.validate(); + if (isLastStep) { + void form.handleSubmit(); + } else { + setStep(s => s + 1); + } + }, [form, isLastStep]); - // Validate specific step fields (optimized) - const validateStep = useCallback( - (stepIndex: number): boolean => { - const fields = stepFields[stepIndex as keyof typeof stepFields] || []; + // Wrap form methods to have generic types for step components + const formProps = { + values: form.values, + errors: form.errors, + touched: form.touched, + setValue: (field: string, value: unknown) => form.setValue(field as keyof SignupFormData, value as never), + setTouchedField: (field: string) => form.setTouchedField(field as keyof SignupFormData), + }; - // Mark fields as touched and check for errors - fields.forEach(field => setTouchedField(field)); - - // Use the validate function to get current validation state - return validate() || !fields.some(field => Boolean(errors[String(field)])); - }, - [stepFields, setTouchedField, validate, errors] - ); - - const steps: FormStep[] = [ - { - key: "personal", - title: "Personal Information", - description: "Tell us about yourself", - content: ( - - ), - }, - { - key: "address", - title: "Address", - description: "Where should we send your SIM?", - content: ( - setValue("address", address)} - setTouchedField={setTouchedField} - /> - ), - }, - { - key: "security", - title: "Security", - description: "Create a secure password", - content: ( - - ), - }, + const stepContent = [ + , + , + , + , ]; - const currentStepFields = stepFields[currentStepIndex as keyof typeof stepFields] ?? []; - const canProceed = - currentStepIndex === steps.length - 1 - ? true - : currentStepFields.every(field => !errors[String(field)]); + const steps = STEPS.map((s, i) => ({ + ...s, + content: stepContent[i], + })); return (
-
-

Create Your Account

-

- Join thousands of customers enjoying reliable connectivity -

-
- { - if (validateStep(currentStepIndex)) { - if (currentStepIndex < steps.length - 1) { - setCurrentStepIndex(currentStepIndex + 1); - } else { - void handleSubmit(); - } - } - }} - onPrevious={() => { - if (currentStepIndex > 0) { - setCurrentStepIndex(currentStepIndex - 1); - } - }} - isLastStep={currentStepIndex === steps.length - 1} - isSubmitting={isSubmitting || loading} - canProceed={canProceed} + currentStep={step} + onNext={handleNext} + onPrevious={() => setStep(s => Math.max(0, s - 1))} + isLastStep={isLastStep} + isSubmitting={form.isSubmitting || loading} + canProceed={true} /> {error && {error}} - {showLoginLink && ( -
-

- Already have an account?{" "} - - Sign in - -

-
- )} +
+

+ Already have an account?{" "} + + Sign in + +

+

+ Existing customer?{" "} + + Migrate your account + +

+
); diff --git a/apps/portal/src/features/auth/components/SignupForm/index.ts b/apps/portal/src/features/auth/components/SignupForm/index.ts index 7ca3bd4c..7aec3070 100644 --- a/apps/portal/src/features/auth/components/SignupForm/index.ts +++ b/apps/portal/src/features/auth/components/SignupForm/index.ts @@ -1,6 +1,3 @@ export { SignupForm } from "./SignupForm"; export { MultiStepForm } from "./MultiStepForm"; -export { AccountStep } from "./AccountStep"; -export { PersonalStep } from "./PersonalStep"; -export { AddressStep } from "./AddressStep"; -export { PreferencesStep } from "./PreferencesStep"; +export * from "./steps"; diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx new file mode 100644 index 00000000..4b7b2827 --- /dev/null +++ b/apps/portal/src/features/auth/components/SignupForm/steps/AccountStep.tsx @@ -0,0 +1,98 @@ +/** + * Account Step - Customer number and contact info + */ + +"use client"; + +import { Input } from "@/components/atoms"; +import { FormField } from "@/components/molecules/FormField/FormField"; + +interface AccountStepProps { + form: { + values: { sfNumber: string; firstName: string; lastName: string; email: string; phone: string; company?: string }; + errors: Record; + touched: Record; + setValue: (field: string, value: unknown) => void; + setTouchedField: (field: string) => void; + }; +} + +export function AccountStep({ form }: AccountStepProps) { + const { values, errors, touched, setValue, setTouchedField } = form; + const getError = (field: string) => (touched[field] ? errors[field] : undefined); + + return ( +
+
+ + setValue("sfNumber", e.target.value)} + onBlur={() => setTouchedField("sfNumber")} + placeholder="e.g., AST-123456" + className="w-full bg-white" + autoFocus + /> + +
+ +
+ + setValue("firstName", e.target.value)} + onBlur={() => setTouchedField("firstName")} + placeholder="Enter your first name" + autoComplete="given-name" + /> + + + setValue("lastName", e.target.value)} + onBlur={() => setTouchedField("lastName")} + placeholder="Enter your last name" + autoComplete="family-name" + /> + +
+ + + setValue("email", e.target.value)} + onBlur={() => setTouchedField("email")} + placeholder="you@example.com" + autoComplete="email" + /> + + + + setValue("phone", e.target.value)} + onBlur={() => setTouchedField("phone")} + placeholder="+81 XX-XXXX-XXXX" + autoComplete="tel" + /> + + + + setValue("company", e.target.value)} + onBlur={() => setTouchedField("company")} + placeholder="Enter your company name" + autoComplete="organization" + /> + +
+ ); +} diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/AddressStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/AddressStep.tsx new file mode 100644 index 00000000..10c11d78 --- /dev/null +++ b/apps/portal/src/features/auth/components/SignupForm/steps/AddressStep.tsx @@ -0,0 +1,125 @@ +/** + * Address Step - Service address + */ + +"use client"; + +import { Input } from "@/components/atoms"; +import { FormField } from "@/components/molecules/FormField/FormField"; +import { COUNTRY_OPTIONS } from "@/lib/constants/countries"; + +interface AddressData { + address1: string; + address2?: string; + city: string; + state: string; + postcode: string; + country: string; + countryCode?: string; +} + +interface AddressStepProps { + form: { + values: { address: AddressData }; + errors: Record; + touched: Record; + setValue: (field: string, value: unknown) => void; + setTouchedField: (field: string) => void; + }; +} + +export function AddressStep({ form }: AddressStepProps) { + const { values, errors, touched, setValue, setTouchedField } = form; + const address = values.address; + + const getError = (field: string) => { + const key = `address.${field}`; + return touched[key] || touched.address ? (errors[key] ?? errors[field]) : undefined; + }; + + const updateAddress = (field: keyof AddressData, value: string) => { + setValue("address", { ...address, [field]: value }); + }; + + const handleCountryChange = (code: string) => { + setValue("address", { ...address, country: code, countryCode: code }); + }; + + const markTouched = () => setTouchedField("address"); + + return ( +
+ + updateAddress("address1", e.target.value)} + onBlur={markTouched} + placeholder="123 Main Street" + autoComplete="address-line1" + autoFocus + /> + + + + updateAddress("address2", e.target.value)} + onBlur={markTouched} + placeholder="Apartment, suite, etc." + autoComplete="address-line2" + /> + + +
+ + updateAddress("city", e.target.value)} + onBlur={markTouched} + placeholder="Tokyo" + autoComplete="address-level2" + /> + + + updateAddress("state", e.target.value)} + onBlur={markTouched} + placeholder="Tokyo" + autoComplete="address-level1" + /> + +
+ +
+ + updateAddress("postcode", e.target.value)} + onBlur={markTouched} + placeholder="100-0001" + autoComplete="postal-code" + /> + + + + +
+ +

+ This address will be used for shipping SIM cards and other deliveries. +

+
+ ); +} diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/PasswordStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/PasswordStep.tsx new file mode 100644 index 00000000..d6604ab2 --- /dev/null +++ b/apps/portal/src/features/auth/components/SignupForm/steps/PasswordStep.tsx @@ -0,0 +1,87 @@ +/** + * Password Step - Password creation with strength indicator + */ + +"use client"; + +import { Input } from "@/components/atoms"; +import { FormField } from "@/components/molecules/FormField/FormField"; +import { checkPasswordStrength, getPasswordStrengthDisplay } from "@customer-portal/domain/auth"; + +interface PasswordStepProps { + form: { + values: { password: string; confirmPassword: string }; + errors: Record; + touched: Record; + setValue: (field: string, value: unknown) => void; + setTouchedField: (field: string) => void; + }; +} + +export function PasswordStep({ form }: PasswordStepProps) { + const { values, errors, touched, setValue, setTouchedField } = form; + const { requirements, strength, isValid } = checkPasswordStrength(values.password); + const { label, colorClass } = getPasswordStrengthDisplay(strength); + const passwordsMatch = values.password === values.confirmPassword; + + return ( +
+ + setValue("password", e.target.value)} + onBlur={() => setTouchedField("password")} + placeholder="Create a secure password" + autoComplete="new-password" + /> + + + {values.password && ( +
+
+
+
+
+ {label} +
+
+ {requirements.map(r => ( +
+ + {r.met ? "✓" : "○"} + + {r.label} +
+ ))} +
+
+ )} + + + setValue("confirmPassword", e.target.value)} + onBlur={() => setTouchedField("confirmPassword")} + placeholder="Re-enter your password" + autoComplete="new-password" + /> + + + {values.confirmPassword && ( +

+ {passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"} +

+ )} +
+ ); +} diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx b/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx new file mode 100644 index 00000000..6613248a --- /dev/null +++ b/apps/portal/src/features/auth/components/SignupForm/steps/ReviewStep.tsx @@ -0,0 +1,107 @@ +/** + * Review Step - Summary and terms acceptance + */ + +"use client"; + +import Link from "next/link"; + +interface ReviewStepProps { + form: { + values: { + firstName: string; + lastName: string; + email: string; + phone: string; + sfNumber: string; + address: { address1: string; city: string; state: string; postcode: string }; + acceptTerms: boolean; + marketingConsent?: boolean; + }; + errors: Record; + touched: Record; + setValue: (field: string, value: unknown) => void; + setTouchedField: (field: string) => void; + }; +} + +export function ReviewStep({ form }: ReviewStepProps) { + const { values, errors, touched, setValue, setTouchedField } = form; + + return ( +
+ {/* Summary */} +
+

Account Summary

+
+
+
Name
+
{values.firstName} {values.lastName}
+
+
+
Email
+
{values.email}
+
+
+
Phone
+
{values.phone}
+
+
+
Customer Number
+
{values.sfNumber}
+
+ {values.address?.address1 && ( +
+
Address
+
+ {values.address.address1}
+ {values.address.city}, {values.address.state} {values.address.postcode} +
+
+ )} +
+
+ + {/* Terms */} +
+ + {touched.acceptTerms && errors.acceptTerms && ( +

{errors.acceptTerms}

+ )} + + +
+ + {/* Ready message */} +
+ By clicking "Create Account", your account will be created and you can start managing your services. +
+
+ ); +} diff --git a/apps/portal/src/features/auth/components/SignupForm/steps/index.ts b/apps/portal/src/features/auth/components/SignupForm/steps/index.ts new file mode 100644 index 00000000..bcfc995d --- /dev/null +++ b/apps/portal/src/features/auth/components/SignupForm/steps/index.ts @@ -0,0 +1,5 @@ +export { AccountStep } from "./AccountStep"; +export { AddressStep } from "./AddressStep"; +export { PasswordStep } from "./PasswordStep"; +export { ReviewStep } from "./ReviewStep"; + diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 346c21b7..1a9e1741 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; } @@ -144,8 +144,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; } }, @@ -340,9 +340,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/auth/views/LinkWhmcsView.tsx b/apps/portal/src/features/auth/views/LinkWhmcsView.tsx index ee2e9e9b..28ae0f15 100644 --- a/apps/portal/src/features/auth/views/LinkWhmcsView.tsx +++ b/apps/portal/src/features/auth/views/LinkWhmcsView.tsx @@ -1,79 +1,79 @@ +/** + * Link WHMCS View - Account migration page + */ + "use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { AuthLayout } from "../components"; import { LinkWhmcsForm } from "@/features/auth/components"; +import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth"; export function LinkWhmcsView() { const router = useRouter(); return ( -
-
-
-
- - - -
-
-

- We've upgraded our customer portal. Use your existing Assist Solutions - credentials to transfer your account and gain access to the new experience. -

-
    -
  • All of your services and billing history will come with you
  • -
  • We'll guide you through creating a new, secure password afterwards
  • -
  • Your previous login credentials will no longer be needed once you transfer
  • -
-
-
-
- - { - const email = result.user.email; - if (result.needsPasswordSet) { - router.push(`/auth/set-password?email=${encodeURIComponent(email)}`); - return; - } - router.push("/dashboard"); - }} - /> - -
-

- Need a new account?{" "} - - Create one here - -

-

- Already transferred your account?{" "} - - Sign in here - -

-
- -
-

How the transfer works

-
    -
  • Enter the email and password you use for the legacy portal
  • -
  • We verify your account and ask you to set a new secure password
  • -
  • All existing subscriptions, invoices, and tickets stay connected
  • -
  • Need help? Contact support and we'll guide you through it
  • +
    + {/* What transfers */} +
    +

    What gets transferred:

    +
      + {MIGRATION_TRANSFER_ITEMS.map((item, i) => ( +
    • + {item} +
    • + ))}
    + + {/* Form */} +
    +

    Enter Legacy Portal Credentials

    +

    Use your previous Assist Solutions portal email and password.

    + { + if (result.needsPasswordSet) { + router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`); + } else { + router.push("/dashboard"); + } + }} + /> +
    + + {/* Links */} +
    + + New customer? Create account + + + Already transferred? Sign in + +
    + + {/* Steps */} +
    +

    How it works

    +
      + {MIGRATION_STEPS.map((step, i) => ( +
    1. + + {i + 1} + + {step} +
    2. + ))} +
    +
    + +

    + Need help? Contact support +

    ); diff --git a/apps/portal/src/features/auth/views/SignupView.tsx b/apps/portal/src/features/auth/views/SignupView.tsx index 6ec93121..d206f69d 100644 --- a/apps/portal/src/features/auth/views/SignupView.tsx +++ b/apps/portal/src/features/auth/views/SignupView.tsx @@ -11,20 +11,10 @@ export function SignupView() { return ( <> -
    -
    -

    What you'll need

    -
      -
    • Your Assist Solutions customer number
    • -
    • Primary contact details and service address
    • -
    • A secure password that meets our enhanced requirements
    • -
    -
    - -
    +
    {/* Full-page loading overlay during authentication */} 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/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/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/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/sim-management/components/SimDetailsCard.tsx b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx index f2c93401..21052552 100644 --- a/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx +++ b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx @@ -10,6 +10,10 @@ import { ExclamationTriangleIcon, XCircleIcon, } from "@heroicons/react/24/outline"; +import type { SimDetails } from "@customer-portal/domain/sim"; + +// Re-export for backwards compatibility +export type { SimDetails }; // Inline formatPlanShort function function formatPlanShort(planCode?: string): string { @@ -26,33 +30,6 @@ function formatPlanShort(planCode?: string): string { return planCode; } -export interface SimDetails { - account: string; - msisdn: string; - iccid?: string; - imsi?: string; - eid?: string; - planCode: string; - status: "active" | "suspended" | "cancelled" | "pending"; - simType: "physical" | "esim"; - size: "standard" | "nano" | "micro" | "esim"; - hasVoice: boolean; - hasSms: boolean; - remainingQuotaKb: number; - remainingQuotaMb: number; - startDate?: string; - ipv4?: string; - ipv6?: string; - voiceMailEnabled?: boolean; - callWaitingEnabled?: boolean; - internationalRoamingEnabled?: boolean; - networkType?: string; - pendingOperations?: Array<{ - operation: string; - scheduledDate: string; - }>; -} - interface SimDetailsCardProps { simDetails: SimDetails; isLoading?: boolean; @@ -292,7 +269,7 @@ export function SimDetailsCard({

    Physical SIM Details

    - {formatPlan(simDetails.planCode)} • {`${simDetails.size} SIM`} + {formatPlan(simDetails.planCode)} • {`${simDetails.simType} SIM`}

@@ -321,12 +298,10 @@ export function SimDetailsCard({

{simDetails.msisdn}

- {simDetails.simType === "physical" && ( -
- -

{simDetails.iccid}

-
- )} +
+ +

{simDetails.iccid}

+
{simDetails.eid && (
@@ -342,10 +317,10 @@ export function SimDetailsCard({
)} - {simDetails.startDate && ( + {simDetails.activatedAt && (
-

{formatDate(simDetails.startDate)}

+

{formatDate(simDetails.activatedAt)}

)}
@@ -368,37 +343,32 @@ export function SimDetailsCard({
- Voice {simDetails.hasVoice ? "Enabled" : "Disabled"} + Voicemail {simDetails.voiceMailEnabled ? "Enabled" : "Disabled"}
- SMS {simDetails.hasSms ? "Enabled" : "Disabled"} + Call Waiting {simDetails.callWaitingEnabled ? "Enabled" : "Disabled"}
- {(simDetails.ipv4 || simDetails.ipv6) && ( -
- -
- {simDetails.ipv4 && ( -

IPv4: {simDetails.ipv4}

- )} - {simDetails.ipv6 && ( -

IPv6: {simDetails.ipv6}

- )} -
+ {simDetails.internationalRoamingEnabled && ( +
+ + + International Roaming Enabled +
)}
@@ -406,21 +376,14 @@ export function SimDetailsCard({ )}
- {/* Pending Operations */} - {simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && ( + {/* Expiry Date */} + {simDetails.expiresAt && (
-

- Pending Operations -

-
- {simDetails.pendingOperations.map((operation, index) => ( -
- - - {operation.operation} scheduled for {formatDate(operation.scheduledDate)} - -
- ))} +
+ + + Expires on {formatDate(simDetails.expiresAt)} +
)} diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index 29eafeac..62b6a395 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -232,10 +232,10 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro

)} diff --git a/apps/portal/src/features/subscriptions/services/sim-actions.service.ts b/apps/portal/src/features/subscriptions/services/sim-actions.service.ts index b4f7b1f8..a5b0e377 100644 --- a/apps/portal/src/features/subscriptions/services/sim-actions.service.ts +++ b/apps/portal/src/features/subscriptions/services/sim-actions.service.ts @@ -4,6 +4,7 @@ import type { SimTopUpRequest, SimPlanChangeRequest, SimCancelRequest, + SimReissueRequest, } from "@customer-portal/domain/sim"; // Types imported from domain - no duplication 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 */} -
- -