Refactor global exception handling and support case management
- Replaced multiple global exception filters with a unified exception filter to streamline error handling across the application. - Removed deprecated AuthErrorFilter and GlobalExceptionFilter to reduce redundancy. - Enhanced SupportController to include new endpoints for listing, retrieving, and creating support cases, improving the support case management functionality. - Integrated SalesforceCaseService for better interaction with Salesforce data in support case operations. - Updated support case schemas to align with new requirements and ensure data consistency.
This commit is contained in:
parent
46c2896935
commit
c7230f391a
@ -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<INestApplication> {
|
||||
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
|
||||
|
||||
|
||||
@ -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<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@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<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
304
apps/bff/src/core/http/exception.filter.ts
Normal file
304
apps/bff/src/core/http/exception.filter.ts
Normal file
@ -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<Response>();
|
||||
const request = ctx.getRequest<Request & { user?: { id?: string }; requestId?: string }>();
|
||||
|
||||
// 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<string, unknown>;
|
||||
|
||||
// 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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Response>();
|
||||
const request = ctx.getRequest<Request & { user?: { id?: string }; requestId?: string }>();
|
||||
|
||||
// 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)}`;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -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<SupportCase[]> {
|
||||
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<SalesforceCaseRecord>;
|
||||
|
||||
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<SupportCase | null> {
|
||||
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<SalesforceCaseRecord>;
|
||||
|
||||
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<string, unknown> = {
|
||||
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<SalesforceCaseRecord | null> {
|
||||
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<SalesforceCaseRecord>;
|
||||
|
||||
return result.records?.[0] ?? null;
|
||||
}
|
||||
}
|
||||
@ -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<SupportCaseList> {
|
||||
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<SupportCase> {
|
||||
return this.supportService.getCase(req.user.id, caseId);
|
||||
}
|
||||
|
||||
@Post("cases")
|
||||
async createCase(
|
||||
@Request() req: RequestWithUser,
|
||||
@Body(new ZodValidationPipe(createCaseRequestSchema))
|
||||
body: CreateCaseRequest
|
||||
): Promise<CreateCaseResponse> {
|
||||
return this.supportService.createCase(req.user.id, body);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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<SupportCaseList> {
|
||||
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<SupportCaseList> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single case by ID
|
||||
*/
|
||||
async getCase(userId: string, caseId: string): Promise<SupportCase> {
|
||||
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<CreateCaseResponse> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
private applyFilters(cases: SupportCase[], filters: SupportCaseFilter): SupportCase[] {
|
||||
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;
|
||||
|
||||
@ -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 <SupportCaseDetailView caseId={id} />;
|
||||
}
|
||||
|
||||
12
apps/portal/src/app/(authenticated)/support/page.tsx
Normal file
12
apps/portal/src/app/(authenticated)/support/page.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { SupportHomeView } from "@/features/support";
|
||||
import { AgentforceWidget } from "@/components";
|
||||
|
||||
export default function SupportPage() {
|
||||
return (
|
||||
<>
|
||||
<SupportHomeView />
|
||||
<AgentforceWidget />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<ErrorState
|
||||
title={"Unable to load"}
|
||||
message={toUserMessage(error)}
|
||||
message={getErrorMessage(error)}
|
||||
variant={variant === "page" ? "page" : variant === "inline" ? "inline" : "card"}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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<string | null>(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 (
|
||||
<div className="fixed bottom-4 right-4 max-w-xs bg-yellow-50 border border-yellow-200 rounded-lg p-3 shadow-lg z-50">
|
||||
<p className="text-xs text-yellow-700">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-blue-600 text-white shadow-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
aria-label={isOpen ? "Close chat" : "Open chat"}
|
||||
>
|
||||
{isOpen ? (
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<ChatBubbleLeftRightIcon className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-16 right-0 w-80 bg-white rounded-lg shadow-xl border border-gray-200">
|
||||
<div className="p-4 border-b border-gray-200 bg-blue-600 rounded-t-lg">
|
||||
<h3 className="text-lg font-medium text-white">AI Assistant</h3>
|
||||
<p className="text-sm text-blue-100">Loading...</p>
|
||||
</div>
|
||||
<div className="p-4 flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Once loaded, Salesforce handles the UI
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
export { AgentforceWidget } from "./AgentforceWidget";
|
||||
|
||||
@ -33,11 +33,11 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/support/kb"
|
||||
href="/support"
|
||||
prefetch
|
||||
aria-label="Help"
|
||||
className="hidden sm:inline-flex p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors"
|
||||
title="Help Center"
|
||||
title="Support Center"
|
||||
>
|
||||
<QuestionMarkCircleIcon className="h-5 w-5" />
|
||||
</Link>
|
||||
|
||||
@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -4,3 +4,4 @@
|
||||
*/
|
||||
|
||||
export { AppShell } from "./AppShell/AppShell";
|
||||
export { AgentforceWidget } from "./AgentforceWidget";
|
||||
|
||||
@ -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<AuthState>()((set, get) => {
|
||||
applyAuthResponse(parsed.data);
|
||||
} catch (error) {
|
||||
logger.error("Failed to refresh session", error);
|
||||
const errorInfo = getErrorInfo(error);
|
||||
const reason = logoutReasonFromErrorCode(errorInfo.code) ?? ("session-expired" as const);
|
||||
const parsed = parseError(error);
|
||||
const reason = logoutReasonFromErrorCode(parsed.code) ?? ("session-expired" as const);
|
||||
await get().logout({ reason });
|
||||
throw error;
|
||||
}
|
||||
@ -141,8 +141,8 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
}
|
||||
applyAuthResponse(parsed.data, true); // Keep loading for redirect
|
||||
} catch (error) {
|
||||
const errorInfo = getErrorInfo(error);
|
||||
set({ loading: false, error: errorInfo.message, isAuthenticated: false });
|
||||
const parsed = parseError(error);
|
||||
set({ loading: false, error: parsed.message, isAuthenticated: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -327,9 +327,9 @@ export const useAuthStore = create<AuthState>()((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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
28
apps/portal/src/features/support/hooks/useCreateCase.ts
Normal file
28
apps/portal/src/features/support/hooks/useCreateCase.ts
Normal file
@ -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<CreateCaseResponse, Error, CreateCaseRequest>({
|
||||
mutationFn: async (data: CreateCaseRequest) => {
|
||||
const response = await apiClient.POST<CreateCaseResponse>("/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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
21
apps/portal/src/features/support/hooks/useSupportCase.ts
Normal file
21
apps/portal/src/features/support/hooks/useSupportCase.ts
Normal file
@ -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<SupportCase>({
|
||||
queryKey: queryKeys.support.case(caseId ?? ""),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.GET<SupportCase>(`/api/support/cases/${caseId}`);
|
||||
return getDataOrThrow(response, "Failed to load support case");
|
||||
},
|
||||
enabled: isAuthenticated && !!caseId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
152
apps/portal/src/features/support/utils/case-presenters.tsx
Normal file
152
apps/portal/src/features/support/utils/case-presenters.tsx
Normal file
@ -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<IconSize, string> = {
|
||||
sm: "h-5 w-5",
|
||||
md: "h-6 w-6",
|
||||
};
|
||||
|
||||
/**
|
||||
* Status to icon mapping
|
||||
*/
|
||||
const STATUS_ICON_MAP: Record<string, (className: string) => ReactNode> = {
|
||||
[SUPPORT_CASE_STATUS.RESOLVED]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.CLOSED]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.VPN_PENDING]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.PENDING]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
|
||||
[SUPPORT_CASE_STATUS.IN_PROGRESS]: (cls) => <ClockIcon className={`${cls} text-blue-500`} />,
|
||||
[SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: (cls) => <ExclamationTriangleIcon className={`${cls} text-amber-500`} />,
|
||||
[SUPPORT_CASE_STATUS.NEW]: (cls) => <SparklesIcon className={`${cls} text-purple-500`} />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Status to variant mapping
|
||||
*/
|
||||
const STATUS_VARIANT_MAP: Record<string, CaseStatusVariant> = {
|
||||
[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<string, CasePriorityVariant> = {
|
||||
[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<CaseStatusVariant, string> = {
|
||||
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<CaseStatusVariant, string> = {
|
||||
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<CasePriorityVariant, string> = {
|
||||
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<CasePriorityVariant, string> = {
|
||||
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 <ChatBubbleLeftRightIcon className={`${sizeClass} text-gray-400`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
|
||||
2
apps/portal/src/features/support/utils/index.ts
Normal file
2
apps/portal/src/features/support/utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./case-presenters";
|
||||
|
||||
@ -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<string | null>(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));
|
||||
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);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create support case");
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
@ -50,54 +56,64 @@ 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 (
|
||||
<div className="py-6">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
|
||||
<PageLayout
|
||||
icon={<TicketIconSolid />}
|
||||
title="Create Support Case"
|
||||
description="Get help from our support team"
|
||||
breadcrumbs={[
|
||||
{ label: "Support", href: "/support" },
|
||||
{ label: "Create Case" },
|
||||
]}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4 mr-1" />
|
||||
Back to Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Create Support Case</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">Get help from our support team</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Tips */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex">
|
||||
{/* AI Chat Suggestion */}
|
||||
<AnimatedCard className="overflow-hidden" variant="highlighted">
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 p-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">Before creating a case</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Check our knowledge base for common solutions</li>
|
||||
<li>Include relevant error messages or screenshots</li>
|
||||
<li>Provide detailed steps to reproduce the issue</li>
|
||||
<li>Mention your service or subscription if applicable</li>
|
||||
</ul>
|
||||
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center backdrop-blur-sm">
|
||||
<SparklesIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-white mb-1">
|
||||
Try our AI Assistant first
|
||||
</h3>
|
||||
<p className="text-blue-100 text-sm">
|
||||
Get instant answers to common questions. If the AI can't help, it will create
|
||||
a case for you automatically.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full sm:w-auto bg-white text-blue-600 hover:bg-blue-50"
|
||||
leftIcon={<ChatBubbleLeftRightIcon className="h-5 w-5" />}
|
||||
>
|
||||
Start Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<AlertBanner variant="error" title="Error creating case" elevated>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<AnimatedCard variant="static">
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Subject *
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-900 mb-2">
|
||||
Subject <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -105,53 +121,35 @@ export function NewSupportCaseView() {
|
||||
value={formData.subject}
|
||||
onChange={event => 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"
|
||||
className="block w-full px-4 py-3 border border-gray-300 rounded-xl text-sm bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category and Priority */}
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={formData.category}
|
||||
onChange={event => handleInputChange("category", event.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="Technical">Technical Support</option>
|
||||
<option value="Billing">Billing Question</option>
|
||||
<option value="General">General Inquiry</option>
|
||||
<option value="Feature Request">Feature Request</option>
|
||||
<option value="Bug Report">Bug Report</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label htmlFor="priority" className="block text-sm font-medium text-gray-900 mb-2">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
id="priority"
|
||||
value={formData.priority}
|
||||
onChange={event => handleInputChange("priority", event.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
className="block w-full sm:w-1/2 px-4 py-3 border border-gray-300 rounded-xl text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
>
|
||||
<option value="Low">Low - General question</option>
|
||||
<option value="Medium">Medium - Issue affecting work</option>
|
||||
<option value="High">High - Service disruption</option>
|
||||
<option value="Critical">Critical - Complete outage</option>
|
||||
{priorityOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description *
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-900 mb-2">
|
||||
Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
@ -159,7 +157,7 @@ export function NewSupportCaseView() {
|
||||
value={formData.description}
|
||||
onChange={event => handleInputChange("description", event.target.value)}
|
||||
placeholder="Please provide a detailed description of your issue, including any error messages and steps to reproduce the problem..."
|
||||
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"
|
||||
className="block w-full px-4 py-3 border border-gray-300 rounded-xl text-sm bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all resize-none"
|
||||
required
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
@ -167,81 +165,85 @@ export function NewSupportCaseView() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Priority Warning */}
|
||||
{formData.priority === "Critical" && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationCircleIcon className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Critical Priority Selected</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>
|
||||
Critical priority should only be used for complete service outages. For
|
||||
urgent issues that aren't complete outages, please use High priority.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-6 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 pt-4 border-t border-gray-100">
|
||||
<Link
|
||||
href="/support"
|
||||
className="text-sm text-gray-600 hover:text-gray-900 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
className="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
disabled={!isFormValid || createCaseMutation.isPending}
|
||||
loading={createCaseMutation.isPending}
|
||||
loadingText="Creating Case..."
|
||||
leftIcon={<PaperAirplaneIcon className="h-4 w-4" />}
|
||||
size="lg"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Creating Case...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PaperAirplaneIcon className="h-4 w-4 mr-2" />
|
||||
Create Case
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Additional Help */}
|
||||
<div className="mt-8 bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Need immediate help?</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">Phone Support</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
9:30-18:00 JST
|
||||
<br />
|
||||
<span className="font-medium text-blue-600">0120-660-470</span>
|
||||
{/* Contact Options */}
|
||||
<AnimatedCard className="overflow-hidden" variant="static">
|
||||
<div className="p-5 border-b border-gray-100">
|
||||
<h3 className="text-base font-semibold text-gray-900">Need immediate assistance?</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Contact us directly for urgent matters</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x divide-gray-100">
|
||||
{/* Phone Support */}
|
||||
<a
|
||||
href="tel:0120660470"
|
||||
className="group flex items-center gap-4 p-5 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform">
|
||||
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900 group-hover:text-emerald-600 transition-colors">
|
||||
Phone Support
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-0.5">0120-660-470</p>
|
||||
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full"></span>
|
||||
Mon-Fri, 9:30-18:00 JST
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">Knowledge Base</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Search our help articles for quick solutions
|
||||
<br />
|
||||
<Link href="/support/kb" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
Browse Knowledge Base
|
||||
</Link>
|
||||
</a>
|
||||
|
||||
{/* AI Chat */}
|
||||
<button
|
||||
type="button"
|
||||
className="group flex items-center gap-4 p-5 hover:bg-gray-50 transition-colors text-left w-full"
|
||||
>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform">
|
||||
<SparklesIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
|
||||
AI Chat Assistant
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-gray-900 mt-0.5">Get instant answers</p>
|
||||
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
|
||||
Available 24/7
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
161
apps/portal/src/features/support/views/SupportCaseDetailView.tsx
Normal file
161
apps/portal/src/features/support/views/SupportCaseDetailView.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
TagIcon,
|
||||
ArrowLeftIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { useSupportCase } from "@/features/support/hooks/useSupportCase";
|
||||
import {
|
||||
getCaseStatusIcon,
|
||||
getCaseStatusClasses,
|
||||
getCasePriorityClasses,
|
||||
} from "@/features/support/utils";
|
||||
|
||||
interface SupportCaseDetailViewProps {
|
||||
caseId: string;
|
||||
}
|
||||
|
||||
export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
|
||||
const { data: supportCase, isLoading, error, refetch } = useSupportCase(caseId);
|
||||
|
||||
if (!isLoading && !supportCase && !error) {
|
||||
return (
|
||||
<PageLayout
|
||||
icon={<TicketIconSolid />}
|
||||
title="Case Not Found"
|
||||
breadcrumbs={[
|
||||
{ label: "Support", href: "/support" },
|
||||
{ label: "Cases", href: "/support/cases" },
|
||||
{ label: "Not Found" },
|
||||
]}
|
||||
>
|
||||
<AlertBanner variant="error" title="Case not found">
|
||||
The support case you're looking for could not be found or you don't have
|
||||
permission to view it.
|
||||
</AlertBanner>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
icon={<TicketIconSolid />}
|
||||
title={supportCase ? `Case #${supportCase.caseNumber}` : "Loading..."}
|
||||
description={supportCase?.subject}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRetry={() => void refetch()}
|
||||
breadcrumbs={[
|
||||
{ label: "Support", href: "/support" },
|
||||
{ label: "Cases", href: "/support/cases" },
|
||||
{ label: supportCase ? `#${supportCase.caseNumber}` : "..." },
|
||||
]}
|
||||
actions={
|
||||
<Button
|
||||
as="a"
|
||||
href="/support/cases"
|
||||
variant="outline"
|
||||
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
|
||||
>
|
||||
All Cases
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{supportCase && (
|
||||
<div className="space-y-6">
|
||||
{/* Header Card - Status & Key Info */}
|
||||
<div className="border border-gray-200 rounded-xl bg-white overflow-hidden">
|
||||
{/* Top Section - Status and Priority */}
|
||||
<div className="p-5 border-b border-gray-100">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{getCaseStatusIcon(supportCase.status, "md")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 leading-snug">
|
||||
{supportCase.subject}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Case #{supportCase.caseNumber}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-3 py-1 rounded-lg text-sm font-medium ${getCaseStatusClasses(supportCase.status)}`}
|
||||
>
|
||||
{supportCase.status}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-3 py-1 rounded-lg text-sm font-medium ${getCasePriorityClasses(supportCase.priority)}`}
|
||||
>
|
||||
{supportCase.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta Info Row */}
|
||||
<div className="px-5 py-3 bg-gray-50/50 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<CalendarIcon className="h-4 w-4 text-gray-400" />
|
||||
<span>Created {format(new Date(supportCase.createdAt), "MMM d, yyyy")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<ClockIcon className="h-4 w-4 text-gray-400" />
|
||||
<span>Updated {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}</span>
|
||||
</div>
|
||||
{supportCase.category && (
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<TagIcon className="h-4 w-4 text-gray-400" />
|
||||
<span>{supportCase.category}</span>
|
||||
</div>
|
||||
)}
|
||||
{supportCase.closedAt && (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<span>✓ Closed {format(new Date(supportCase.closedAt), "MMM d, yyyy")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="border border-gray-200 rounded-xl bg-white overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-100">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Description</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="prose prose-sm max-w-none text-gray-600">
|
||||
<p className="whitespace-pre-wrap leading-relaxed m-0">{supportCase.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="rounded-lg bg-blue-50 border border-blue-100 px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-sm text-blue-700">
|
||||
<p className="font-medium">Need to update this case?</p>
|
||||
<p className="mt-0.5 text-blue-600">Reply via email and your response will be added to this case automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@ -1,32 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useDeferredValue, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ChatBubbleLeftRightIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
ChevronRightIcon,
|
||||
CalendarIcon,
|
||||
UserIcon,
|
||||
TicketIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { format } from "date-fns";
|
||||
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { EmptyState, SearchEmptyState } from "@/components/atoms/empty-state";
|
||||
import { useSupportCases } from "@/features/support/hooks/useSupportCases";
|
||||
import {
|
||||
SUPPORT_CASE_PRIORITY,
|
||||
SUPPORT_CASE_STATUS,
|
||||
type SupportCaseFilter,
|
||||
type SupportCasePriority,
|
||||
type SupportCaseStatus,
|
||||
} from "@customer-portal/domain/support";
|
||||
import {
|
||||
getCaseStatusIcon,
|
||||
getCaseStatusClasses,
|
||||
getCasePriorityClasses,
|
||||
} from "@/features/support/utils";
|
||||
|
||||
export function SupportCasesView() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<SupportCaseStatus | "all">("all");
|
||||
const [priorityFilter, setPriorityFilter] = useState<SupportCasePriority | "all">("all");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>("all");
|
||||
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||
|
||||
const queryFilters = useMemo(() => {
|
||||
@ -43,13 +51,15 @@ export function SupportCasesView() {
|
||||
return nextFilters;
|
||||
}, [statusFilter, priorityFilter, deferredSearchTerm]);
|
||||
|
||||
const { data, isLoading, error } = useSupportCases(queryFilters);
|
||||
const { data, isLoading, error, refetch } = useSupportCases(queryFilters);
|
||||
const cases = data?.cases ?? [];
|
||||
const summary = data?.summary ?? { total: 0, open: 0, highPriority: 0, resolved: 0 };
|
||||
|
||||
const hasActiveFilters = statusFilter !== "all" || priorityFilter !== "all" || searchTerm.trim();
|
||||
|
||||
const statusFilterOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all" as const, label: "All Statuses" },
|
||||
{ value: "all", label: "All Statuses" },
|
||||
...Object.values(SUPPORT_CASE_STATUS).map(status => ({ value: status, label: status })),
|
||||
],
|
||||
[]
|
||||
@ -57,7 +67,7 @@ export function SupportCasesView() {
|
||||
|
||||
const priorityFilterOptions = useMemo(
|
||||
() => [
|
||||
{ value: "all" as const, label: "All Priorities" },
|
||||
{ value: "all", label: "All Priorities" },
|
||||
...Object.values(SUPPORT_CASE_PRIORITY).map(priority => ({
|
||||
value: priority,
|
||||
label: priority,
|
||||
@ -66,184 +76,82 @@ export function SupportCasesView() {
|
||||
[]
|
||||
);
|
||||
|
||||
const getStatusIcon = (status: SupportCaseStatus) => {
|
||||
switch (status) {
|
||||
case SUPPORT_CASE_STATUS.RESOLVED:
|
||||
case SUPPORT_CASE_STATUS.CLOSED:
|
||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
||||
case SUPPORT_CASE_STATUS.IN_PROGRESS:
|
||||
return <ClockIcon className="h-5 w-5 text-blue-500" />;
|
||||
case SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER:
|
||||
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
|
||||
case SUPPORT_CASE_STATUS.OPEN:
|
||||
return <ChatBubbleLeftRightIcon className="h-5 w-5 text-gray-500" />;
|
||||
default:
|
||||
return <ChatBubbleLeftRightIcon className="h-5 w-5 text-gray-500" />;
|
||||
}
|
||||
const clearFilters = () => {
|
||||
setSearchTerm("");
|
||||
setStatusFilter("all");
|
||||
setPriorityFilter("all");
|
||||
};
|
||||
|
||||
const getStatusColor = (status: SupportCaseStatus) => {
|
||||
switch (status) {
|
||||
case SUPPORT_CASE_STATUS.RESOLVED:
|
||||
case SUPPORT_CASE_STATUS.CLOSED:
|
||||
return "bg-green-100 text-green-800";
|
||||
case SUPPORT_CASE_STATUS.IN_PROGRESS:
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER:
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case SUPPORT_CASE_STATUS.OPEN:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: SupportCasePriority) => {
|
||||
switch (priority) {
|
||||
case SUPPORT_CASE_PRIORITY.CRITICAL:
|
||||
return "bg-red-100 text-red-800";
|
||||
case SUPPORT_CASE_PRIORITY.HIGH:
|
||||
return "bg-orange-100 text-orange-800";
|
||||
case SUPPORT_CASE_PRIORITY.MEDIUM:
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case SUPPORT_CASE_PRIORITY.LOW:
|
||||
return "bg-green-100 text-green-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading support cases...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-6">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Support Cases</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">Track and manage your support requests</p>
|
||||
</div>
|
||||
<Link
|
||||
<PageLayout
|
||||
icon={<ChatBubbleLeftRightIconSolid />}
|
||||
title="Support Cases"
|
||||
description="Track and manage your support requests"
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRetry={() => void refetch()}
|
||||
breadcrumbs={[
|
||||
{ label: "Support", href: "/support" },
|
||||
{ label: "Cases" },
|
||||
]}
|
||||
actions={
|
||||
<Button
|
||||
as="a"
|
||||
href="/support/new"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
leftIcon={<TicketIcon className="h-4 w-4" />}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
New Case
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* Summary Strip */}
|
||||
<div className="flex flex-wrap items-center gap-6 px-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ChatBubbleLeftRightIcon className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-600">Total</span>
|
||||
<span className="font-semibold text-gray-900">{summary.total}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ClockIcon className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-gray-600">Open</span>
|
||||
<span className="font-semibold text-blue-600">{summary.open}</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800">
|
||||
{error instanceof Error ? error.message : "Failed to load support cases"}
|
||||
{summary.highPriority > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-gray-600">High Priority</span>
|
||||
<span className="font-semibold text-amber-600">{summary.highPriority}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<ChatBubbleLeftRightIcon className="h-6 w-6 text-gray-600" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Total Cases</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{summary.total}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
<span className="text-gray-600">Resolved</span>
|
||||
<span className="font-semibold text-green-600">{summary.resolved}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<ClockIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Open Cases</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{summary.open}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">High Priority</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{summary.highPriority}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Resolved</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{summary.resolved}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white shadow rounded-lg mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search & Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search cases..."
|
||||
placeholder="Search by case number or subject..."
|
||||
value={searchTerm}
|
||||
onChange={event => setSearchTerm(event.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="block w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="relative">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={event =>
|
||||
setStatusFilter(event.target.value as SupportCaseStatus | "all")
|
||||
}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
onChange={event => setStatusFilter(event.target.value)}
|
||||
className="appearance-none pl-3 pr-8 py-2 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
|
||||
>
|
||||
{statusFilterOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@ -251,16 +159,11 @@ export function SupportCasesView() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={event =>
|
||||
setPriorityFilter(event.target.value as SupportCasePriority | "all")
|
||||
}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
onChange={event => setPriorityFilter(event.target.value)}
|
||||
className="appearance-none pl-3 pr-8 py-2 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
|
||||
>
|
||||
{priorityFilterOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@ -268,100 +171,91 @@ export function SupportCasesView() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cases List */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{cases.length > 0 ? (
|
||||
<div className="border border-gray-200 rounded-xl bg-white divide-y divide-gray-100 overflow-hidden">
|
||||
{cases.map(supportCase => (
|
||||
<li key={supportCase.id}>
|
||||
<Link
|
||||
href={`/support/cases/${supportCase.id}`}
|
||||
className="block hover:bg-gray-50"
|
||||
<div
|
||||
key={supportCase.id}
|
||||
onClick={() => router.push(`/support/cases/${supportCase.id}`)}
|
||||
className="flex items-center gap-4 p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
|
||||
>
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<div className="flex-shrink-0">{getStatusIcon(supportCase.status)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<p className="text-sm font-medium text-blue-600 truncate">
|
||||
#{supportCase.id} - {supportCase.subject}
|
||||
</p>
|
||||
{/* Status Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{getCaseStatusIcon(supportCase.status)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
#{supportCase.caseNumber}
|
||||
</span>
|
||||
<span className="text-sm text-gray-900 truncate">
|
||||
{supportCase.subject}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(supportCase.status)}`}
|
||||
className={`inline-flex text-xs px-2 py-0.5 rounded font-medium ${getCaseStatusClasses(supportCase.status)}`}
|
||||
>
|
||||
{supportCase.status}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPriorityColor(supportCase.priority)}`}
|
||||
className={`inline-flex text-xs px-2 py-0.5 rounded font-medium ${getCasePriorityClasses(supportCase.priority)}`}
|
||||
>
|
||||
{supportCase.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-2 line-clamp-2">
|
||||
{supportCase.description}
|
||||
</p>
|
||||
<div className="flex items-center space-x-6 text-xs text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<CalendarIcon className="h-4 w-4 mr-1" />
|
||||
Created: {format(new Date(supportCase.createdAt), "MMM d, yyyy")}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CalendarIcon className="h-4 w-4 mr-1" />
|
||||
Updated: {format(new Date(supportCase.updatedAt), "MMM d, yyyy")}
|
||||
</div>
|
||||
{supportCase.assignedTo && (
|
||||
<div className="flex items-center">
|
||||
<UserIcon className="h-4 w-4 mr-1" />
|
||||
{supportCase.assignedTo}
|
||||
</div>
|
||||
)}
|
||||
<span className="capitalize bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs">
|
||||
{supportCase.category && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{supportCase.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ChevronRightIcon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Empty State */}
|
||||
{cases.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<ChatBubbleLeftRightIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No support cases found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchTerm || statusFilter !== "all" || priorityFilter !== "all"
|
||||
? "Try adjusting your search or filter criteria"
|
||||
: "Your support cases will appear here when you create them"}
|
||||
{/* Timestamp */}
|
||||
<div className="hidden sm:block text-right flex-shrink-0">
|
||||
<p className="text-xs text-gray-400">
|
||||
Updated {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
|
||||
</p>
|
||||
{searchTerm === "" && statusFilter === "all" && priorityFilter === "all" && (
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href="/support/new"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Create Your First Case
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<ChevronRightIcon className="h-4 w-4 text-gray-300 group-hover:text-gray-400 flex-shrink-0 transition-colors" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : hasActiveFilters ? (
|
||||
<AnimatedCard className="p-8" variant="static">
|
||||
<SearchEmptyState searchTerm={searchTerm || "filters"} onClearSearch={clearFilters} />
|
||||
</AnimatedCard>
|
||||
) : (
|
||||
<AnimatedCard className="p-8" variant="static">
|
||||
<EmptyState
|
||||
icon={<TicketIcon className="h-12 w-12" />}
|
||||
title="No support cases found"
|
||||
description="You haven't created any support cases yet. Need help? Create a new case."
|
||||
action={{
|
||||
label: "Create Case",
|
||||
onClick: () => router.push("/support/new"),
|
||||
}}
|
||||
/>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
161
apps/portal/src/features/support/views/SupportHomeView.tsx
Normal file
161
apps/portal/src/features/support/views/SupportHomeView.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ChatBubbleLeftRightIcon,
|
||||
SparklesIcon,
|
||||
PlusIcon,
|
||||
ChevronRightIcon,
|
||||
TicketIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { EmptyState } from "@/components/atoms/empty-state";
|
||||
import { useSupportCases } from "@/features/support/hooks/useSupportCases";
|
||||
import { getCaseStatusIcon, getCaseStatusClasses } from "@/features/support/utils";
|
||||
|
||||
export function SupportHomeView() {
|
||||
const router = useRouter();
|
||||
const { data, isLoading, error, refetch } = useSupportCases();
|
||||
const recentCases = data?.cases?.slice(0, 5) ?? [];
|
||||
const summary = data?.summary ?? { total: 0, open: 0, highPriority: 0, resolved: 0 };
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
icon={<ChatBubbleLeftRightIconSolid />}
|
||||
title="Support Center"
|
||||
description="Get help with your account and services"
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRetry={() => void refetch()}
|
||||
>
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* AI Assistant Card */}
|
||||
<div className="bg-gradient-to-br from-blue-600 to-blue-700 rounded-xl p-5 text-white">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<SparklesIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold mb-1">AI Assistant</h3>
|
||||
<p className="text-sm text-blue-100 mb-4 leading-relaxed">
|
||||
Get instant answers to common questions about your account.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-white text-blue-600 hover:bg-blue-50 shadow-sm"
|
||||
>
|
||||
Start Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Case Card */}
|
||||
<div className="border border-gray-200 rounded-xl p-5 bg-white">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<PlusIcon className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">Create Support Case</h3>
|
||||
<p className="text-sm text-gray-500 mb-4 leading-relaxed">
|
||||
Our team typically responds within 24 hours.
|
||||
</p>
|
||||
<Button
|
||||
as="a"
|
||||
href="/support/new"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
New Case
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cases Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Your Cases</h3>
|
||||
{summary.total > 0 && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="flex items-center gap-1.5 text-gray-500">
|
||||
<ClockIcon className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium text-blue-600">{summary.open}</span> open
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-gray-500">
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
<span className="font-medium text-green-600">{summary.resolved}</span> resolved
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{summary.total > 0 && (
|
||||
<Link
|
||||
href="/support/cases"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium inline-flex items-center gap-1"
|
||||
>
|
||||
View all
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recentCases.length > 0 ? (
|
||||
<div className="border border-gray-200 rounded-xl bg-white divide-y divide-gray-100 overflow-hidden">
|
||||
{recentCases.map(supportCase => (
|
||||
<div
|
||||
key={supportCase.id}
|
||||
onClick={() => router.push(`/support/cases/${supportCase.id}`)}
|
||||
className="flex items-center gap-4 p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{getCaseStatusIcon(supportCase.status)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
#{supportCase.caseNumber}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded font-medium ${getCaseStatusClasses(supportCase.status)}`}
|
||||
>
|
||||
{supportCase.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 truncate">{supportCase.subject}</p>
|
||||
</div>
|
||||
<div className="hidden sm:block text-xs text-gray-400 flex-shrink-0">
|
||||
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
|
||||
</div>
|
||||
<ChevronRightIcon className="h-4 w-4 text-gray-300 group-hover:text-gray-400 flex-shrink-0 transition-colors" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<AnimatedCard className="p-8" variant="static">
|
||||
<EmptyState
|
||||
icon={<TicketIcon className="h-12 w-12" />}
|
||||
title="No support cases yet"
|
||||
description="Need help? Start a chat with our AI assistant or create a support case."
|
||||
action={{
|
||||
label: "Create Case",
|
||||
onClick: () => router.push("/support/new"),
|
||||
}}
|
||||
/>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
export * from "./NewSupportCaseView";
|
||||
export * from "./SupportCasesView";
|
||||
export * from "./SupportCaseDetailView";
|
||||
export * from "./SupportHomeView";
|
||||
|
||||
@ -15,6 +15,58 @@ export * from "./response-helpers";
|
||||
import { createClient, ApiError } from "./runtime/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
/**
|
||||
* Auth endpoints that should NOT trigger automatic logout on 401
|
||||
* These are endpoints where 401 means "invalid credentials", not "session expired"
|
||||
*/
|
||||
const AUTH_ENDPOINTS = [
|
||||
"/api/auth/login",
|
||||
"/api/auth/signup",
|
||||
"/api/auth/link-whmcs",
|
||||
"/api/auth/set-password",
|
||||
"/api/auth/reset-password",
|
||||
"/api/auth/check-password-needed",
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a URL path is an auth endpoint
|
||||
*/
|
||||
function isAuthEndpoint(url: string): boolean {
|
||||
try {
|
||||
const urlPath = new URL(url).pathname;
|
||||
return AUTH_ENDPOINTS.some(endpoint => urlPath.endsWith(endpoint));
|
||||
} catch {
|
||||
return AUTH_ENDPOINTS.some(endpoint => url.includes(endpoint));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from API error body
|
||||
* Handles both `{ message }` and `{ error: { message } }` formats
|
||||
*/
|
||||
function extractErrorMessage(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for nested error.message format (standard API error response)
|
||||
const bodyWithError = body as { error?: { message?: unknown } };
|
||||
if (bodyWithError.error && typeof bodyWithError.error === "object") {
|
||||
const errorMessage = bodyWithError.error.message;
|
||||
if (typeof errorMessage === "string") {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for top-level message
|
||||
const bodyWithMessage = body as { message?: unknown };
|
||||
if (typeof bodyWithMessage.message === "string") {
|
||||
return bodyWithMessage.message;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global error handler for API client
|
||||
* Handles authentication errors and triggers logout when needed
|
||||
@ -23,7 +75,10 @@ async function handleApiError(response: Response): Promise<void> {
|
||||
// Don't import useAuthStore at module level to avoid circular dependencies
|
||||
// We'll handle auth errors by dispatching a custom event that the auth system can listen to
|
||||
|
||||
if (response.status === 401) {
|
||||
// Only dispatch logout event for 401s on non-auth endpoints
|
||||
// Auth endpoints (login, signup, etc.) return 401 for invalid credentials,
|
||||
// which should NOT trigger logout - just show the error message
|
||||
if (response.status === 401 && !isAuthEndpoint(response.url)) {
|
||||
logger.warn("Received 401 Unauthorized response - triggering logout");
|
||||
|
||||
// Dispatch a custom event that the auth system will listen to
|
||||
@ -45,11 +100,9 @@ async function handleApiError(response: Response): Promise<void> {
|
||||
const contentType = cloned.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
body = await cloned.json();
|
||||
if (body && typeof body === "object" && "message" in body) {
|
||||
const maybeMessage = (body as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === "string") {
|
||||
message = maybeMessage;
|
||||
}
|
||||
const extractedMessage = extractErrorMessage(body);
|
||||
if (extractedMessage) {
|
||||
message = extractedMessage;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@ -104,6 +157,7 @@ export const queryKeys = {
|
||||
},
|
||||
support: {
|
||||
cases: (params?: Record<string, unknown>) => ["support", "cases", params] as const,
|
||||
case: (id: string) => ["support", "case", id] as const,
|
||||
},
|
||||
currency: {
|
||||
default: () => ["currency", "default"] as const,
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Error display utilities
|
||||
* Converts errors to user-friendly messages
|
||||
*/
|
||||
|
||||
import { isApiError } from "@/lib/api/runtime/client";
|
||||
|
||||
export function toUserMessage(error: unknown): string {
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Handle API errors with specific status codes
|
||||
if (isApiError(error)) {
|
||||
const status = error.response?.status;
|
||||
const body = error.body as Record<string, unknown> | undefined;
|
||||
|
||||
// Rate limit error (429)
|
||||
if (status === 429) {
|
||||
return "Too many requests. Please wait a moment and try again.";
|
||||
}
|
||||
|
||||
// Get message from error body
|
||||
if (body && typeof body.error === "object") {
|
||||
const errorObj = body.error as Record<string, unknown>;
|
||||
if (typeof errorObj.message === "string") {
|
||||
return errorObj.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (body && typeof body.message === "string") {
|
||||
return body.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
return String(error.message);
|
||||
}
|
||||
|
||||
return "An unexpected error occurred";
|
||||
}
|
||||
@ -1,256 +1,254 @@
|
||||
/**
|
||||
* Standardized Error Handling for Portal
|
||||
* Provides consistent error handling and user-friendly messages
|
||||
* Unified Error Handling for Portal
|
||||
*
|
||||
* Clean, simple error handling that uses shared error codes from domain package.
|
||||
* Provides consistent error parsing and user-friendly messages.
|
||||
*/
|
||||
|
||||
import { ApiError as ClientApiError } from "@/lib/api";
|
||||
import { apiErrorResponseSchema, type ApiErrorResponse } from "@customer-portal/domain/common";
|
||||
import { ApiError as ClientApiError, isApiError } from "@/lib/api";
|
||||
import {
|
||||
ErrorCode,
|
||||
ErrorMessages,
|
||||
ErrorMetadata,
|
||||
matchErrorPattern,
|
||||
type ErrorCodeType,
|
||||
} from "@customer-portal/domain/common";
|
||||
|
||||
export type ApiErrorPayload = ApiErrorResponse;
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiErrorInfo {
|
||||
code: string;
|
||||
export interface ParsedError {
|
||||
code: ErrorCodeType;
|
||||
message: string;
|
||||
shouldLogout?: boolean;
|
||||
shouldRetry?: boolean;
|
||||
shouldLogout: boolean;
|
||||
shouldRetry: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Parsing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse any error into a structured format with code and user-friendly message.
|
||||
* This is the main entry point for error handling.
|
||||
*/
|
||||
export function parseError(error: unknown): ParsedError {
|
||||
// Handle API client errors
|
||||
if (isApiError(error)) {
|
||||
return parseApiError(error);
|
||||
}
|
||||
|
||||
// Handle network/fetch errors
|
||||
if (error instanceof Error) {
|
||||
return parseNativeError(error);
|
||||
}
|
||||
|
||||
// Handle string errors
|
||||
if (typeof error === "string") {
|
||||
const code = matchErrorPattern(error);
|
||||
const metadata = ErrorMetadata[code];
|
||||
return {
|
||||
code,
|
||||
message: error,
|
||||
shouldLogout: metadata.shouldLogout,
|
||||
shouldRetry: metadata.shouldRetry,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown error type
|
||||
return {
|
||||
code: ErrorCode.UNKNOWN,
|
||||
message: ErrorMessages[ErrorCode.UNKNOWN],
|
||||
shouldLogout: false,
|
||||
shouldRetry: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error information from various error types
|
||||
* Parse API client error
|
||||
*/
|
||||
export function getErrorInfo(error: unknown): ApiErrorInfo {
|
||||
if (error instanceof ClientApiError) {
|
||||
const info = parseClientApiError(error);
|
||||
if (info) {
|
||||
return info;
|
||||
function parseApiError(error: ClientApiError): ParsedError {
|
||||
const body = error.body;
|
||||
const status = error.response?.status;
|
||||
|
||||
// Try to extract from standard API error response format
|
||||
if (body && typeof body === "object") {
|
||||
const bodyObj = body as Record<string, unknown>;
|
||||
|
||||
// Check for standard { success: false, error: { code, message } } format
|
||||
if (
|
||||
bodyObj.success === false &&
|
||||
bodyObj.error &&
|
||||
typeof bodyObj.error === "object"
|
||||
) {
|
||||
const errorObj = bodyObj.error as Record<string, unknown>;
|
||||
const code = typeof errorObj.code === "string" ? errorObj.code : undefined;
|
||||
const message = typeof errorObj.message === "string" ? errorObj.message : undefined;
|
||||
|
||||
if (code && message) {
|
||||
const metadata = ErrorMetadata[code as ErrorCodeType] ?? ErrorMetadata[ErrorCode.UNKNOWN];
|
||||
return {
|
||||
code: code as ErrorCodeType,
|
||||
message,
|
||||
shouldLogout: metadata.shouldLogout,
|
||||
shouldRetry: metadata.shouldRetry,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle fetch/network errors
|
||||
if (error instanceof Error) {
|
||||
// Try extracting message from body
|
||||
const extractedMessage = extractMessageFromBody(body);
|
||||
if (extractedMessage) {
|
||||
const code = matchErrorPattern(extractedMessage);
|
||||
const metadata = ErrorMetadata[code];
|
||||
return {
|
||||
code,
|
||||
message: extractedMessage,
|
||||
shouldLogout: metadata.shouldLogout,
|
||||
shouldRetry: metadata.shouldRetry,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to status code mapping
|
||||
const code = mapStatusToErrorCode(status);
|
||||
const metadata = ErrorMetadata[code];
|
||||
return {
|
||||
code,
|
||||
message: error.message || ErrorMessages[code],
|
||||
shouldLogout: metadata.shouldLogout,
|
||||
shouldRetry: metadata.shouldRetry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse native JavaScript errors (network, timeout, etc.)
|
||||
*/
|
||||
function parseNativeError(error: Error): ParsedError {
|
||||
// Network errors
|
||||
if (error.name === "TypeError" && error.message.includes("fetch")) {
|
||||
return {
|
||||
code: "NETWORK_ERROR",
|
||||
message:
|
||||
"Unable to connect to the server. Please check your internet connection and try again.",
|
||||
code: ErrorCode.NETWORK_ERROR,
|
||||
message: ErrorMessages[ErrorCode.NETWORK_ERROR],
|
||||
shouldLogout: false,
|
||||
shouldRetry: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Timeout errors
|
||||
if (error.name === "AbortError") {
|
||||
return {
|
||||
code: "REQUEST_TIMEOUT",
|
||||
message: "The request timed out. Please try again.",
|
||||
code: ErrorCode.TIMEOUT,
|
||||
message: ErrorMessages[ErrorCode.TIMEOUT],
|
||||
shouldLogout: false,
|
||||
shouldRetry: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Try pattern matching on error message
|
||||
const code = matchErrorPattern(error.message);
|
||||
const metadata = ErrorMetadata[code];
|
||||
return {
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: "An unexpected error occurred. Please try again.",
|
||||
shouldRetry: true,
|
||||
code,
|
||||
message: code === ErrorCode.UNKNOWN ? error.message : ErrorMessages[code],
|
||||
shouldLogout: metadata.shouldLogout,
|
||||
shouldRetry: metadata.shouldRetry,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown error types
|
||||
return {
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: "An unexpected error occurred. Please try again.",
|
||||
shouldRetry: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isApiErrorPayload(error: unknown): error is ApiErrorPayload {
|
||||
const parsed = apiErrorResponseSchema.safeParse(error);
|
||||
return parsed.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user should be logged out for this error
|
||||
* Extract error message from response body
|
||||
*/
|
||||
function shouldLogoutForError(code: string): boolean {
|
||||
const logoutCodes = ["TOKEN_REVOKED", "INVALID_REFRESH_TOKEN"];
|
||||
return logoutCodes.includes(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the request should be retried for this error
|
||||
*/
|
||||
function shouldRetryForError(code: string): boolean {
|
||||
const noRetryCodes = [
|
||||
"INVALID_CREDENTIALS",
|
||||
"FORBIDDEN",
|
||||
"ADMIN_REQUIRED",
|
||||
"ACCOUNT_LOCKED",
|
||||
"VALIDATION_ERROR",
|
||||
"ACCOUNT_ALREADY_LINKED",
|
||||
"ACCOUNT_EXISTS",
|
||||
"CUSTOMER_NOT_FOUND",
|
||||
"INVALID_REQUEST",
|
||||
];
|
||||
return !noRetryCodes.includes(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message for display in UI
|
||||
*/
|
||||
export function getUserFriendlyMessage(error: unknown): string {
|
||||
const errorInfo = getErrorInfo(error);
|
||||
return errorInfo.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication errors consistently
|
||||
*/
|
||||
export function handleAuthError(error: unknown, logout: () => void | Promise<void>): boolean {
|
||||
const errorInfo = getErrorInfo(error);
|
||||
|
||||
if (errorInfo.shouldLogout) {
|
||||
void logout();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized error for logging
|
||||
*/
|
||||
export function createErrorLog(
|
||||
error: unknown,
|
||||
context: string
|
||||
): {
|
||||
context: string;
|
||||
code: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
} {
|
||||
const errorInfo = getErrorInfo(error);
|
||||
|
||||
return {
|
||||
context,
|
||||
code: errorInfo.code,
|
||||
message: errorInfo.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseClientApiError(error: ClientApiError): ApiErrorInfo | null {
|
||||
const status = error.response?.status;
|
||||
const parsedBody = parseRawErrorBody(error.body);
|
||||
|
||||
const payloadInfo = parsedBody ? deriveInfoFromPayload(parsedBody, status) : null;
|
||||
|
||||
if (payloadInfo) {
|
||||
return payloadInfo;
|
||||
}
|
||||
|
||||
return {
|
||||
code: status ? httpStatusCodeToLabel(status) : "API_ERROR",
|
||||
message: error.message,
|
||||
shouldLogout: status ? shouldLogoutForError(httpStatusCodeToLabel(status)) : false,
|
||||
shouldRetry: typeof status === "number" ? status >= 500 : true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRawErrorBody(body: unknown): unknown {
|
||||
if (!body) {
|
||||
function extractMessageFromBody(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
return body;
|
||||
const bodyObj = body as Record<string, unknown>;
|
||||
|
||||
// Check nested error.message (standard format)
|
||||
if (bodyObj.error && typeof bodyObj.error === "object") {
|
||||
const errorObj = bodyObj.error as Record<string, unknown>;
|
||||
if (typeof errorObj.message === "string") {
|
||||
return errorObj.message;
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function deriveInfoFromPayload(payload: unknown, status?: number): ApiErrorInfo | null {
|
||||
const parsed = apiErrorResponseSchema.safeParse(payload);
|
||||
if (parsed.success) {
|
||||
const code = parsed.data.error.code;
|
||||
return {
|
||||
code,
|
||||
message: parsed.data.error.message,
|
||||
shouldLogout: shouldLogoutForError(code),
|
||||
shouldRetry: shouldRetryForError(code),
|
||||
};
|
||||
}
|
||||
|
||||
if (isGlobalErrorPayload(payload)) {
|
||||
const code = payload.code || payload.error || httpStatusCodeToLabel(status);
|
||||
const message = payload.message || "Request failed. Please try again.";
|
||||
const derivedStatus = payload.statusCode ?? status;
|
||||
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
shouldLogout: shouldLogoutForError(code),
|
||||
shouldRetry:
|
||||
typeof derivedStatus === "number" ? derivedStatus >= 500 : shouldRetryForError(code),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
"message" in payload &&
|
||||
typeof (payload as { message?: unknown }).message === "string"
|
||||
) {
|
||||
const payloadWithMessage = payload as { code?: unknown; message: string };
|
||||
const candidateCode = payloadWithMessage.code;
|
||||
const code = typeof candidateCode === "string" ? candidateCode : httpStatusCodeToLabel(status);
|
||||
|
||||
return {
|
||||
code,
|
||||
message: payloadWithMessage.message,
|
||||
shouldLogout: shouldLogoutForError(code),
|
||||
shouldRetry: typeof status === "number" ? status >= 500 : shouldRetryForError(code),
|
||||
};
|
||||
// Check top-level message
|
||||
if (typeof bodyObj.message === "string") {
|
||||
return bodyObj.message;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isGlobalErrorPayload(payload: unknown): payload is {
|
||||
success: false;
|
||||
code?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
statusCode?: number;
|
||||
} {
|
||||
return (
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
"success" in payload &&
|
||||
(payload as { success?: unknown }).success === false &&
|
||||
("code" in payload || "message" in payload || "error" in payload)
|
||||
);
|
||||
}
|
||||
|
||||
function httpStatusCodeToLabel(status?: number): string {
|
||||
if (!status) {
|
||||
return "API_ERROR";
|
||||
}
|
||||
/**
|
||||
* Map HTTP status code to error code
|
||||
*/
|
||||
function mapStatusToErrorCode(status?: number): ErrorCodeType {
|
||||
if (!status) return ErrorCode.UNKNOWN;
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
return "BAD_REQUEST";
|
||||
return ErrorCode.VALIDATION_FAILED;
|
||||
case 401:
|
||||
return "UNAUTHORIZED";
|
||||
return ErrorCode.SESSION_EXPIRED;
|
||||
case 403:
|
||||
return "FORBIDDEN";
|
||||
return ErrorCode.FORBIDDEN;
|
||||
case 404:
|
||||
return "NOT_FOUND";
|
||||
return ErrorCode.NOT_FOUND;
|
||||
case 409:
|
||||
return "CONFLICT";
|
||||
case 422:
|
||||
return "UNPROCESSABLE_ENTITY";
|
||||
return ErrorCode.ACCOUNT_EXISTS;
|
||||
case 429:
|
||||
return ErrorCode.RATE_LIMITED;
|
||||
case 503:
|
||||
return ErrorCode.SERVICE_UNAVAILABLE;
|
||||
default:
|
||||
return status >= 500 ? "SERVER_ERROR" : `HTTP_${status}`;
|
||||
return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get user-friendly error message from any error
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
return parseError(error).message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error should trigger logout
|
||||
*/
|
||||
export function shouldLogout(error: unknown): boolean {
|
||||
return parseError(error).shouldLogout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error can be retried
|
||||
*/
|
||||
export function canRetry(error: unknown): boolean {
|
||||
return parseError(error).shouldRetry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error code from any error
|
||||
*/
|
||||
export function getErrorCode(error: unknown): ErrorCodeType {
|
||||
return parseError(error).code;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Re-exports from domain package for convenience
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
ErrorCode,
|
||||
ErrorMessages,
|
||||
ErrorMetadata,
|
||||
matchErrorPattern,
|
||||
type ErrorCodeType,
|
||||
} from "@customer-portal/domain/common";
|
||||
|
||||
@ -1,2 +1,12 @@
|
||||
export { cn } from "./cn";
|
||||
export { toUserMessage } from "./error-display";
|
||||
export {
|
||||
parseError,
|
||||
getErrorMessage,
|
||||
shouldLogout,
|
||||
canRetry,
|
||||
getErrorCode,
|
||||
ErrorCode,
|
||||
ErrorMessages,
|
||||
type ParsedError,
|
||||
type ErrorCodeType,
|
||||
} from "./error-handling";
|
||||
|
||||
@ -185,3 +185,12 @@ export function getCatalogProductPriceDisplay(item: CatalogProductBase): Catalog
|
||||
currency,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate savings percentage between original and current price
|
||||
* Returns 0 if there are no savings (current >= original)
|
||||
*/
|
||||
export function calculateSavingsPercentage(originalPrice: number, currentPrice: number): number {
|
||||
if (originalPrice <= currentPrice) return 0;
|
||||
return Math.round(((originalPrice - currentPrice) / originalPrice) * 100);
|
||||
}
|
||||
|
||||
479
packages/domain/common/errors.ts
Normal file
479
packages/domain/common/errors.ts
Normal file
@ -0,0 +1,479 @@
|
||||
/**
|
||||
* Unified Error Handling for Customer Portal
|
||||
*
|
||||
* Single source of truth for error codes and user-friendly messages.
|
||||
* Used by both BFF (for generating responses) and Portal (for parsing responses).
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ============================================================================
|
||||
// Error Categories and Severity
|
||||
// ============================================================================
|
||||
|
||||
export type ErrorCategory =
|
||||
| "authentication"
|
||||
| "authorization"
|
||||
| "validation"
|
||||
| "business"
|
||||
| "system"
|
||||
| "network";
|
||||
|
||||
export type ErrorSeverity = "low" | "medium" | "high" | "critical";
|
||||
|
||||
// ============================================================================
|
||||
// Error Code Registry
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* All error codes used in the application.
|
||||
* Format: CATEGORY_NUMBER (e.g., AUTH_001, VAL_001)
|
||||
*/
|
||||
export const ErrorCode = {
|
||||
// Authentication Errors (AUTH_*)
|
||||
INVALID_CREDENTIALS: "AUTH_001",
|
||||
ACCOUNT_LOCKED: "AUTH_002",
|
||||
SESSION_EXPIRED: "AUTH_003",
|
||||
TOKEN_INVALID: "AUTH_004",
|
||||
TOKEN_REVOKED: "AUTH_005",
|
||||
REFRESH_TOKEN_INVALID: "AUTH_006",
|
||||
|
||||
// Authorization Errors (AUTHZ_*)
|
||||
FORBIDDEN: "AUTHZ_001",
|
||||
ADMIN_REQUIRED: "AUTHZ_002",
|
||||
RESOURCE_ACCESS_DENIED: "AUTHZ_003",
|
||||
|
||||
// Validation Errors (VAL_*)
|
||||
VALIDATION_FAILED: "VAL_001",
|
||||
REQUIRED_FIELD_MISSING: "VAL_002",
|
||||
INVALID_FORMAT: "VAL_003",
|
||||
NOT_FOUND: "VAL_004",
|
||||
|
||||
// Business Logic Errors (BIZ_*)
|
||||
ACCOUNT_EXISTS: "BIZ_001",
|
||||
ACCOUNT_ALREADY_LINKED: "BIZ_002",
|
||||
CUSTOMER_NOT_FOUND: "BIZ_003",
|
||||
ORDER_ALREADY_PROCESSED: "BIZ_004",
|
||||
INSUFFICIENT_BALANCE: "BIZ_005",
|
||||
SERVICE_UNAVAILABLE: "BIZ_006",
|
||||
|
||||
// System Errors (SYS_*)
|
||||
INTERNAL_ERROR: "SYS_001",
|
||||
EXTERNAL_SERVICE_ERROR: "SYS_002",
|
||||
DATABASE_ERROR: "SYS_003",
|
||||
CONFIGURATION_ERROR: "SYS_004",
|
||||
|
||||
// Network/Rate Limiting (NET_*)
|
||||
NETWORK_ERROR: "NET_001",
|
||||
TIMEOUT: "NET_002",
|
||||
RATE_LIMITED: "NET_003",
|
||||
|
||||
// Generic
|
||||
UNKNOWN: "GEN_001",
|
||||
} as const;
|
||||
|
||||
export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||||
|
||||
// ============================================================================
|
||||
// Error Messages (User-Friendly)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* User-friendly error messages for each error code.
|
||||
* These are safe to display to end users.
|
||||
*/
|
||||
export const ErrorMessages: Record<ErrorCodeType, string> = {
|
||||
// Authentication
|
||||
[ErrorCode.INVALID_CREDENTIALS]: "Invalid email or password. Please try again.",
|
||||
[ErrorCode.ACCOUNT_LOCKED]:
|
||||
"Your account has been temporarily locked due to multiple failed login attempts. Please try again later.",
|
||||
[ErrorCode.SESSION_EXPIRED]: "Your session has expired. Please log in again.",
|
||||
[ErrorCode.TOKEN_INVALID]: "Your session is invalid. Please log in again.",
|
||||
[ErrorCode.TOKEN_REVOKED]: "Your session has been revoked. Please log in again.",
|
||||
[ErrorCode.REFRESH_TOKEN_INVALID]: "Your session could not be refreshed. Please log in again.",
|
||||
|
||||
// Authorization
|
||||
[ErrorCode.FORBIDDEN]: "You do not have permission to perform this action.",
|
||||
[ErrorCode.ADMIN_REQUIRED]: "Administrator access is required for this action.",
|
||||
[ErrorCode.RESOURCE_ACCESS_DENIED]: "You do not have access to this resource.",
|
||||
|
||||
// Validation
|
||||
[ErrorCode.VALIDATION_FAILED]: "The provided data is invalid. Please check your input.",
|
||||
[ErrorCode.REQUIRED_FIELD_MISSING]: "Required information is missing.",
|
||||
[ErrorCode.INVALID_FORMAT]: "The data format is invalid.",
|
||||
[ErrorCode.NOT_FOUND]: "The requested resource was not found.",
|
||||
|
||||
// Business Logic
|
||||
[ErrorCode.ACCOUNT_EXISTS]: "An account with this email already exists. Please sign in.",
|
||||
[ErrorCode.ACCOUNT_ALREADY_LINKED]: "This billing account is already linked. Please sign in.",
|
||||
[ErrorCode.CUSTOMER_NOT_FOUND]: "Customer account not found. Please contact support.",
|
||||
[ErrorCode.ORDER_ALREADY_PROCESSED]: "This order has already been processed.",
|
||||
[ErrorCode.INSUFFICIENT_BALANCE]: "Insufficient account balance.",
|
||||
[ErrorCode.SERVICE_UNAVAILABLE]: "This service is temporarily unavailable. Please try again later.",
|
||||
|
||||
// System
|
||||
[ErrorCode.INTERNAL_ERROR]: "An unexpected error occurred. Please try again later.",
|
||||
[ErrorCode.EXTERNAL_SERVICE_ERROR]: "An external service is temporarily unavailable. Please try again later.",
|
||||
[ErrorCode.DATABASE_ERROR]: "A system error occurred. Please try again later.",
|
||||
[ErrorCode.CONFIGURATION_ERROR]: "A system configuration error occurred. Please contact support.",
|
||||
|
||||
// Network
|
||||
[ErrorCode.NETWORK_ERROR]: "Unable to connect to the server. Please check your internet connection.",
|
||||
[ErrorCode.TIMEOUT]: "The request timed out. Please try again.",
|
||||
[ErrorCode.RATE_LIMITED]: "Too many requests. Please wait a moment and try again.",
|
||||
|
||||
// Generic
|
||||
[ErrorCode.UNKNOWN]: "An unexpected error occurred. Please try again.",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Error Metadata
|
||||
// ============================================================================
|
||||
|
||||
interface ErrorMetadata {
|
||||
category: ErrorCategory;
|
||||
severity: ErrorSeverity;
|
||||
shouldLogout: boolean;
|
||||
shouldRetry: boolean;
|
||||
logLevel: "error" | "warn" | "info" | "debug";
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for each error code defining behavior and classification.
|
||||
*/
|
||||
export const ErrorMetadata: Record<ErrorCodeType, ErrorMetadata> = {
|
||||
// Authentication - mostly medium severity, some trigger logout
|
||||
[ErrorCode.INVALID_CREDENTIALS]: {
|
||||
category: "authentication",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "warn",
|
||||
},
|
||||
[ErrorCode.ACCOUNT_LOCKED]: {
|
||||
category: "authentication",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "warn",
|
||||
},
|
||||
[ErrorCode.SESSION_EXPIRED]: {
|
||||
category: "authentication",
|
||||
severity: "low",
|
||||
shouldLogout: true,
|
||||
shouldRetry: false,
|
||||
logLevel: "info",
|
||||
},
|
||||
[ErrorCode.TOKEN_INVALID]: {
|
||||
category: "authentication",
|
||||
severity: "medium",
|
||||
shouldLogout: true,
|
||||
shouldRetry: false,
|
||||
logLevel: "warn",
|
||||
},
|
||||
[ErrorCode.TOKEN_REVOKED]: {
|
||||
category: "authentication",
|
||||
severity: "medium",
|
||||
shouldLogout: true,
|
||||
shouldRetry: false,
|
||||
logLevel: "warn",
|
||||
},
|
||||
[ErrorCode.REFRESH_TOKEN_INVALID]: {
|
||||
category: "authentication",
|
||||
severity: "medium",
|
||||
shouldLogout: true,
|
||||
shouldRetry: false,
|
||||
logLevel: "warn",
|
||||
},
|
||||
|
||||
// Authorization
|
||||
[ErrorCode.FORBIDDEN]: {
|
||||
category: "authorization",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "warn",
|
||||
},
|
||||
[ErrorCode.ADMIN_REQUIRED]: {
|
||||
category: "authorization",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "warn",
|
||||
},
|
||||
[ErrorCode.RESOURCE_ACCESS_DENIED]: {
|
||||
category: "authorization",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "warn",
|
||||
},
|
||||
|
||||
// Validation - low severity, informational
|
||||
[ErrorCode.VALIDATION_FAILED]: {
|
||||
category: "validation",
|
||||
severity: "low",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "info",
|
||||
},
|
||||
[ErrorCode.REQUIRED_FIELD_MISSING]: {
|
||||
category: "validation",
|
||||
severity: "low",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "info",
|
||||
},
|
||||
[ErrorCode.INVALID_FORMAT]: {
|
||||
category: "validation",
|
||||
severity: "low",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "info",
|
||||
},
|
||||
[ErrorCode.NOT_FOUND]: {
|
||||
category: "validation",
|
||||
severity: "low",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "info",
|
||||
},
|
||||
|
||||
// Business Logic
|
||||
[ErrorCode.ACCOUNT_EXISTS]: {
|
||||
category: "business",
|
||||
severity: "low",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "info",
|
||||
},
|
||||
[ErrorCode.ACCOUNT_ALREADY_LINKED]: {
|
||||
category: "business",
|
||||
severity: "low",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "info",
|
||||
},
|
||||
[ErrorCode.CUSTOMER_NOT_FOUND]: {
|
||||
category: "business",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "warn",
|
||||
},
|
||||
[ErrorCode.ORDER_ALREADY_PROCESSED]: {
|
||||
category: "business",
|
||||
severity: "low",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "info",
|
||||
},
|
||||
[ErrorCode.INSUFFICIENT_BALANCE]: {
|
||||
category: "business",
|
||||
severity: "low",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "info",
|
||||
},
|
||||
[ErrorCode.SERVICE_UNAVAILABLE]: {
|
||||
category: "business",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: true,
|
||||
logLevel: "warn",
|
||||
},
|
||||
|
||||
// System - high severity
|
||||
[ErrorCode.INTERNAL_ERROR]: {
|
||||
category: "system",
|
||||
severity: "high",
|
||||
shouldLogout: false,
|
||||
shouldRetry: true,
|
||||
logLevel: "error",
|
||||
},
|
||||
[ErrorCode.EXTERNAL_SERVICE_ERROR]: {
|
||||
category: "system",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: true,
|
||||
logLevel: "error",
|
||||
},
|
||||
[ErrorCode.DATABASE_ERROR]: {
|
||||
category: "system",
|
||||
severity: "critical",
|
||||
shouldLogout: false,
|
||||
shouldRetry: true,
|
||||
logLevel: "error",
|
||||
},
|
||||
[ErrorCode.CONFIGURATION_ERROR]: {
|
||||
category: "system",
|
||||
severity: "critical",
|
||||
shouldLogout: false,
|
||||
shouldRetry: false,
|
||||
logLevel: "error",
|
||||
},
|
||||
|
||||
// Network
|
||||
[ErrorCode.NETWORK_ERROR]: {
|
||||
category: "network",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: true,
|
||||
logLevel: "warn",
|
||||
},
|
||||
[ErrorCode.TIMEOUT]: {
|
||||
category: "network",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: true,
|
||||
logLevel: "warn",
|
||||
},
|
||||
[ErrorCode.RATE_LIMITED]: {
|
||||
category: "network",
|
||||
severity: "low",
|
||||
shouldLogout: false,
|
||||
shouldRetry: true,
|
||||
logLevel: "warn",
|
||||
},
|
||||
|
||||
// Generic
|
||||
[ErrorCode.UNKNOWN]: {
|
||||
category: "system",
|
||||
severity: "medium",
|
||||
shouldLogout: false,
|
||||
shouldRetry: true,
|
||||
logLevel: "error",
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get user-friendly message for an error code
|
||||
*/
|
||||
export function getErrorMessage(code: string): string {
|
||||
return ErrorMessages[code as ErrorCodeType] ?? ErrorMessages[ErrorCode.UNKNOWN];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for an error code
|
||||
*/
|
||||
export function getErrorMetadata(code: string): ErrorMetadata {
|
||||
return ErrorMetadata[code as ErrorCodeType] ?? ErrorMetadata[ErrorCode.UNKNOWN];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error code should trigger logout
|
||||
*/
|
||||
export function shouldLogoutForCode(code: string): boolean {
|
||||
return getErrorMetadata(code).shouldLogout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error can be retried
|
||||
*/
|
||||
export function canRetryError(code: string): boolean {
|
||||
return getErrorMetadata(code).shouldRetry;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pattern Matching for Error Classification
|
||||
// ============================================================================
|
||||
|
||||
interface ErrorPattern {
|
||||
pattern: RegExp;
|
||||
code: ErrorCodeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patterns to match error messages to error codes.
|
||||
* Used when explicit error codes are not available.
|
||||
*/
|
||||
export const ErrorPatterns: ErrorPattern[] = [
|
||||
// Authentication patterns
|
||||
{ pattern: /invalid.*credentials?|wrong.*password|invalid.*password/i, code: ErrorCode.INVALID_CREDENTIALS },
|
||||
{ pattern: /account.*locked|locked.*account|too.*many.*attempts/i, code: ErrorCode.ACCOUNT_LOCKED },
|
||||
{ pattern: /session.*expired|expired.*session/i, code: ErrorCode.SESSION_EXPIRED },
|
||||
{ pattern: /token.*expired|expired.*token/i, code: ErrorCode.SESSION_EXPIRED },
|
||||
{ pattern: /token.*revoked|revoked.*token/i, code: ErrorCode.TOKEN_REVOKED },
|
||||
{ pattern: /invalid.*token|token.*invalid/i, code: ErrorCode.TOKEN_INVALID },
|
||||
{ pattern: /refresh.*token.*invalid|invalid.*refresh/i, code: ErrorCode.REFRESH_TOKEN_INVALID },
|
||||
|
||||
// Authorization patterns
|
||||
{ pattern: /admin.*required|requires?.*admin/i, code: ErrorCode.ADMIN_REQUIRED },
|
||||
{ pattern: /forbidden|not.*authorized|unauthorized/i, code: ErrorCode.FORBIDDEN },
|
||||
{ pattern: /access.*denied|permission.*denied/i, code: ErrorCode.RESOURCE_ACCESS_DENIED },
|
||||
|
||||
// Business patterns
|
||||
{ pattern: /already.*exists|email.*exists|account.*exists/i, code: ErrorCode.ACCOUNT_EXISTS },
|
||||
{ pattern: /already.*linked/i, code: ErrorCode.ACCOUNT_ALREADY_LINKED },
|
||||
{ pattern: /customer.*not.*found|account.*not.*found/i, code: ErrorCode.CUSTOMER_NOT_FOUND },
|
||||
{ pattern: /already.*processed/i, code: ErrorCode.ORDER_ALREADY_PROCESSED },
|
||||
{ pattern: /insufficient.*balance/i, code: ErrorCode.INSUFFICIENT_BALANCE },
|
||||
|
||||
// System patterns
|
||||
{ pattern: /database|sql|postgres|prisma|connection.*refused/i, code: ErrorCode.DATABASE_ERROR },
|
||||
{ pattern: /whmcs|salesforce|external.*service/i, code: ErrorCode.EXTERNAL_SERVICE_ERROR },
|
||||
{ pattern: /configuration.*error|missing.*config/i, code: ErrorCode.CONFIGURATION_ERROR },
|
||||
|
||||
// Network patterns
|
||||
{ pattern: /network.*error|fetch.*failed|econnrefused/i, code: ErrorCode.NETWORK_ERROR },
|
||||
{ pattern: /timeout|timed?\s*out/i, code: ErrorCode.TIMEOUT },
|
||||
{ pattern: /too.*many.*requests|rate.*limit/i, code: ErrorCode.RATE_LIMITED },
|
||||
|
||||
// Validation patterns (lower priority - checked last)
|
||||
{ pattern: /not.*found/i, code: ErrorCode.NOT_FOUND },
|
||||
{ pattern: /validation.*failed|invalid/i, code: ErrorCode.VALIDATION_FAILED },
|
||||
{ pattern: /required|missing/i, code: ErrorCode.REQUIRED_FIELD_MISSING },
|
||||
];
|
||||
|
||||
/**
|
||||
* Match an error message to an error code using patterns
|
||||
*/
|
||||
export function matchErrorPattern(message: string): ErrorCodeType {
|
||||
for (const { pattern, code } of ErrorPatterns) {
|
||||
if (pattern.test(message)) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
return ErrorCode.UNKNOWN;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Zod Schema for Error Response
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Schema for standard API error response
|
||||
*/
|
||||
export const apiErrorSchema = z.object({
|
||||
success: z.literal(false),
|
||||
error: z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ApiError = z.infer<typeof apiErrorSchema>;
|
||||
|
||||
/**
|
||||
* Create a standard error response object
|
||||
*/
|
||||
export function createErrorResponse(
|
||||
code: ErrorCodeType,
|
||||
customMessage?: string,
|
||||
details?: Record<string, unknown>
|
||||
): ApiError {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message: customMessage ?? getErrorMessage(code),
|
||||
details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
export * from "./types";
|
||||
export * from "./schema";
|
||||
export * from "./validation";
|
||||
export * from "./errors";
|
||||
|
||||
// Common provider types (generic wrappers used across domains)
|
||||
export * as CommonProviders from "./providers/index";
|
||||
|
||||
@ -4,3 +4,4 @@
|
||||
|
||||
export * from "./contract";
|
||||
export * from "./schema";
|
||||
export * from "./utils";
|
||||
|
||||
104
packages/domain/dashboard/utils.ts
Normal file
104
packages/domain/dashboard/utils.ts
Normal file
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Dashboard Domain Utilities
|
||||
* Business logic for dashboard activities and task generation
|
||||
*/
|
||||
|
||||
import type { Activity, ActivityFilter, ActivityFilterConfig, ActivityType } from "./contract";
|
||||
|
||||
/**
|
||||
* Activity filter configurations
|
||||
* Defines which activity types belong to each filter category
|
||||
*/
|
||||
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 category
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity types that support navigation to detail views
|
||||
*/
|
||||
const CLICKABLE_ACTIVITY_TYPES: ActivityType[] = ["invoice_created", "invoice_paid"];
|
||||
|
||||
/**
|
||||
* Check if an activity is clickable (has a navigable detail view)
|
||||
*/
|
||||
export function isActivityClickable(activity: Activity): boolean {
|
||||
return CLICKABLE_ACTIVITY_TYPES.includes(activity.type) && !!activity.relatedId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard task definition
|
||||
*/
|
||||
export interface DashboardTask {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard summary input for task generation
|
||||
*/
|
||||
export interface DashboardTaskSummary {
|
||||
nextInvoice?: { id: number } | null;
|
||||
stats?: { unpaidInvoices?: number; openCases?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dashboard task suggestions based on summary data
|
||||
*/
|
||||
export function generateDashboardTasks(summary: DashboardTaskSummary): DashboardTask[] {
|
||||
const tasks: DashboardTask[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -2,26 +2,73 @@
|
||||
* Support Domain - Contract
|
||||
*
|
||||
* Constants for support case statuses, priorities, and categories.
|
||||
* These are the DISPLAY values shown in the portal UI.
|
||||
*
|
||||
* Note: Salesforce uses Japanese API names internally (e.g., 新規, 対応中, 高, 中, 低)
|
||||
* which are mapped to these English display values by the Salesforce mapper.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Portal display status values
|
||||
* Mapped from Salesforce Japanese API names:
|
||||
* - 新規 → New
|
||||
* - 対応中 → In Progress
|
||||
* - Awaiting Approval → Awaiting Approval
|
||||
* - VPN Pending → VPN Pending
|
||||
* - Pending → Pending
|
||||
* - 完了済み → Resolved
|
||||
* - Closed → Closed
|
||||
*/
|
||||
export const SUPPORT_CASE_STATUS = {
|
||||
OPEN: "Open",
|
||||
NEW: "New",
|
||||
IN_PROGRESS: "In Progress",
|
||||
WAITING_ON_CUSTOMER: "Waiting on Customer",
|
||||
AWAITING_APPROVAL: "Awaiting Approval",
|
||||
VPN_PENDING: "VPN Pending",
|
||||
PENDING: "Pending",
|
||||
RESOLVED: "Resolved",
|
||||
CLOSED: "Closed",
|
||||
} as const;
|
||||
|
||||
/** Statuses that indicate a case is closed */
|
||||
export const CLOSED_STATUSES = [
|
||||
SUPPORT_CASE_STATUS.VPN_PENDING,
|
||||
SUPPORT_CASE_STATUS.PENDING,
|
||||
SUPPORT_CASE_STATUS.RESOLVED,
|
||||
SUPPORT_CASE_STATUS.CLOSED,
|
||||
] as const;
|
||||
|
||||
/** Statuses that indicate a case is open */
|
||||
export const OPEN_STATUSES = [
|
||||
SUPPORT_CASE_STATUS.NEW,
|
||||
SUPPORT_CASE_STATUS.IN_PROGRESS,
|
||||
SUPPORT_CASE_STATUS.AWAITING_APPROVAL,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Portal display priority values
|
||||
* Mapped from Salesforce Japanese API names:
|
||||
* - 高 → High
|
||||
* - 中 → Medium
|
||||
* - 低 → Low
|
||||
*/
|
||||
export const SUPPORT_CASE_PRIORITY = {
|
||||
LOW: "Low",
|
||||
MEDIUM: "Medium",
|
||||
HIGH: "High",
|
||||
CRITICAL: "Critical",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Case categories map to Salesforce Case.Type field
|
||||
* Note: Type picklist may not be configured in your org
|
||||
*/
|
||||
export const SUPPORT_CASE_CATEGORY = {
|
||||
TECHNICAL: "Technical",
|
||||
BILLING: "Billing",
|
||||
GENERAL: "General",
|
||||
FEATURE_REQUEST: "Feature Request",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Portal Website origin - used to filter and create portal cases
|
||||
*/
|
||||
export const PORTAL_CASE_ORIGIN = "Portal Website" as const;
|
||||
|
||||
@ -1,7 +1,25 @@
|
||||
/**
|
||||
* Support Domain
|
||||
*
|
||||
* Exports all support-related contracts, schemas, and provider mappers.
|
||||
*
|
||||
* Types are derived from Zod schemas (Schema-First Approach)
|
||||
*/
|
||||
|
||||
// Business types and constants
|
||||
export {
|
||||
SUPPORT_CASE_STATUS,
|
||||
SUPPORT_CASE_PRIORITY,
|
||||
SUPPORT_CASE_CATEGORY,
|
||||
PORTAL_CASE_ORIGIN,
|
||||
} from "./contract";
|
||||
|
||||
// Schemas (includes derived types)
|
||||
export * from "./schema";
|
||||
|
||||
// Provider adapters
|
||||
export * as Providers from "./providers/index";
|
||||
|
||||
// Re-export provider types for convenience
|
||||
export * from "./providers/salesforce/raw.types";
|
||||
export * from "./providers/salesforce/mapper";
|
||||
|
||||
18
packages/domain/support/providers/index.ts
Normal file
18
packages/domain/support/providers/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Support Domain - Providers
|
||||
*/
|
||||
|
||||
import * as SalesforceMapper from "./salesforce/mapper";
|
||||
import * as SalesforceRaw from "./salesforce/raw.types";
|
||||
|
||||
export const Salesforce = {
|
||||
...SalesforceMapper,
|
||||
...SalesforceRaw,
|
||||
mapper: SalesforceMapper,
|
||||
raw: SalesforceRaw,
|
||||
};
|
||||
|
||||
export { SalesforceMapper, SalesforceRaw };
|
||||
export * from "./salesforce/mapper";
|
||||
export * from "./salesforce/raw.types";
|
||||
|
||||
7
packages/domain/support/providers/salesforce/index.ts
Normal file
7
packages/domain/support/providers/salesforce/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Support Domain - Salesforce Provider
|
||||
*/
|
||||
|
||||
export * from "./raw.types";
|
||||
export * from "./mapper";
|
||||
|
||||
170
packages/domain/support/providers/salesforce/mapper.ts
Normal file
170
packages/domain/support/providers/salesforce/mapper.ts
Normal file
@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Support Domain - Salesforce Provider Mapper
|
||||
*
|
||||
* Transform functions to convert raw Salesforce Case records to domain types.
|
||||
*/
|
||||
|
||||
import { supportCaseSchema, type SupportCase } from "../../schema";
|
||||
import type { SalesforceCaseRecord } from "./raw.types";
|
||||
import {
|
||||
getStatusDisplayLabel,
|
||||
getPriorityDisplayLabel,
|
||||
SALESFORCE_CASE_STATUS,
|
||||
SALESFORCE_CASE_PRIORITY,
|
||||
} from "./raw.types";
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Safely coerce a value to string or return undefined
|
||||
*/
|
||||
function ensureString(value: unknown): string | undefined {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current ISO timestamp
|
||||
*/
|
||||
function nowIsoString(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transform Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Transform a raw Salesforce Case record to a portal SupportCase.
|
||||
*
|
||||
* Converts Salesforce API values (often in Japanese) to portal display labels (English).
|
||||
*
|
||||
* @param record - Raw Salesforce Case record from SOQL query
|
||||
* @returns Validated SupportCase domain object
|
||||
*/
|
||||
export function transformSalesforceCaseToSupportCase(
|
||||
record: SalesforceCaseRecord
|
||||
): SupportCase {
|
||||
// Get raw values
|
||||
const rawStatus = ensureString(record.Status) ?? SALESFORCE_CASE_STATUS.NEW;
|
||||
const rawPriority = ensureString(record.Priority) ?? SALESFORCE_CASE_PRIORITY.MEDIUM;
|
||||
|
||||
return supportCaseSchema.parse({
|
||||
id: record.Id,
|
||||
caseNumber: record.CaseNumber,
|
||||
subject: ensureString(record.Subject) ?? "",
|
||||
// Convert Japanese SF values to English display labels
|
||||
status: getStatusDisplayLabel(rawStatus),
|
||||
priority: getPriorityDisplayLabel(rawPriority),
|
||||
category: ensureString(record.Type) ?? null,
|
||||
description: ensureString(record.Description) ?? "",
|
||||
createdAt: ensureString(record.CreatedDate) ?? nowIsoString(),
|
||||
updatedAt: ensureString(record.LastModifiedDate) ?? nowIsoString(),
|
||||
closedAt: ensureString(record.ClosedDate) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform multiple Salesforce Case records to SupportCase array.
|
||||
*
|
||||
* @param records - Array of raw Salesforce Case records
|
||||
* @returns Array of validated SupportCase domain objects
|
||||
*/
|
||||
export function transformSalesforceCasesToSupportCases(
|
||||
records: SalesforceCaseRecord[]
|
||||
): SupportCase[] {
|
||||
return records.map(transformSalesforceCaseToSupportCase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SOQL SELECT fields for Case queries.
|
||||
*
|
||||
* Standard Salesforce Case fields based on org configuration.
|
||||
* Note: Type field is not accessible via API in this org.
|
||||
*
|
||||
* @param additionalFields - Optional additional fields to include
|
||||
* @returns Array of field names for SOQL SELECT clause
|
||||
*/
|
||||
export function buildCaseSelectFields(additionalFields: string[] = []): string[] {
|
||||
const baseFields = [
|
||||
// Core identifiers
|
||||
"Id",
|
||||
"CaseNumber",
|
||||
|
||||
// Case content
|
||||
"Subject",
|
||||
"Description",
|
||||
|
||||
// Picklist fields
|
||||
"Status",
|
||||
"Priority",
|
||||
"Origin",
|
||||
|
||||
// Relationships
|
||||
"AccountId",
|
||||
"ContactId",
|
||||
"OwnerId",
|
||||
|
||||
// Timestamps
|
||||
"CreatedDate",
|
||||
"LastModifiedDate",
|
||||
"ClosedDate",
|
||||
|
||||
// Flags
|
||||
"IsEscalated",
|
||||
];
|
||||
|
||||
return [...new Set([...baseFields, ...additionalFields])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SOQL query for fetching cases for an account.
|
||||
*
|
||||
* @param accountId - Salesforce Account ID
|
||||
* @param origin - Case origin to filter by (e.g., "Portal Website")
|
||||
* @param additionalFields - Optional additional fields to include
|
||||
* @returns SOQL query string
|
||||
*/
|
||||
export function buildCasesForAccountQuery(
|
||||
accountId: string,
|
||||
origin: string,
|
||||
additionalFields: string[] = []
|
||||
): string {
|
||||
const fields = buildCaseSelectFields(additionalFields).join(", ");
|
||||
return `
|
||||
SELECT ${fields}
|
||||
FROM Case
|
||||
WHERE AccountId = '${accountId}' AND Origin = '${origin}'
|
||||
ORDER BY CreatedDate DESC
|
||||
LIMIT 100
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SOQL query for fetching a single case by ID.
|
||||
*
|
||||
* @param caseId - Salesforce Case ID
|
||||
* @param accountId - Salesforce Account ID (for ownership validation)
|
||||
* @param origin - Case origin to filter by
|
||||
* @param additionalFields - Optional additional fields to include
|
||||
* @returns SOQL query string
|
||||
*/
|
||||
export function buildCaseByIdQuery(
|
||||
caseId: string,
|
||||
accountId: string,
|
||||
origin: string,
|
||||
additionalFields: string[] = []
|
||||
): string {
|
||||
const fields = buildCaseSelectFields(additionalFields).join(", ");
|
||||
return `
|
||||
SELECT ${fields}
|
||||
FROM Case
|
||||
WHERE Id = '${caseId}' AND AccountId = '${accountId}' AND Origin = '${origin}'
|
||||
LIMIT 1
|
||||
`.trim();
|
||||
}
|
||||
|
||||
322
packages/domain/support/providers/salesforce/raw.types.ts
Normal file
322
packages/domain/support/providers/salesforce/raw.types.ts
Normal file
@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Support Domain - Salesforce Provider Raw Types
|
||||
*
|
||||
* Raw Salesforce API response types for Case sobject.
|
||||
* These types represent the exact structure returned by Salesforce SOQL queries.
|
||||
*
|
||||
* Available Case fields in this org (standard fields used):
|
||||
* - Id, CaseNumber, Subject, Status, Priority, Type, Description, Origin, Reason
|
||||
* - AccountId, ContactId, OwnerId, ParentId
|
||||
* - CreatedDate, ClosedDate (LastModifiedDate via LastModifiedById)
|
||||
* - IsEscalated, IsClosedOnCreate, IsStopped
|
||||
* - ContactEmail, ContactPhone, ContactMobile, ContactFax
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ============================================================================
|
||||
// Salesforce Case Record (Raw API Response)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Raw Salesforce Case record schema
|
||||
*
|
||||
* Represents the structure returned by SOQL queries on the Case object.
|
||||
* All fields are optional/nullable to handle partial queries and Salesforce nulls.
|
||||
*/
|
||||
export const salesforceCaseRecordSchema = z.object({
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Standard Salesforce ID fields
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Id: z.string(),
|
||||
CaseNumber: z.string(),
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Core case fields (Picklists & Text)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Subject: z.string().nullable().optional(), // Text(255)
|
||||
Status: z.string().nullable().optional(), // Picklist
|
||||
Priority: z.string().nullable().optional(), // Picklist
|
||||
Type: z.string().nullable().optional(), // Picklist - Maps to Category in portal
|
||||
Description: z.string().nullable().optional(), // Long Text Area(32000)
|
||||
Origin: z.string().nullable().optional(), // Picklist (Case Origin)
|
||||
Reason: z.string().nullable().optional(), // Picklist (Case Reason)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Relationship IDs (Lookups)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
AccountId: z.string().nullable().optional(), // Lookup(Account)
|
||||
ContactId: z.string().nullable().optional(), // Lookup(Contact)
|
||||
OwnerId: z.string().nullable().optional(), // Lookup(User,Group)
|
||||
ParentId: z.string().nullable().optional(), // Lookup(Case)
|
||||
AssetId: z.string().nullable().optional(), // Lookup(Asset)
|
||||
ProductId: z.string().nullable().optional(), // Lookup(Product)
|
||||
EntitlementId: z.string().nullable().optional(), // Lookup(Entitlement)
|
||||
ServiceContractId: z.string().nullable().optional(), // Lookup(Service Contract)
|
||||
SourceId: z.string().nullable().optional(), // Lookup(Chat Transcript,Email Message,etc)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Nested objects (from relationship queries)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Account: z
|
||||
.object({
|
||||
Id: z.string().optional(),
|
||||
Name: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
|
||||
Contact: z
|
||||
.object({
|
||||
Id: z.string().optional(),
|
||||
Name: z.string().nullable().optional(),
|
||||
Email: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
|
||||
Owner: z
|
||||
.object({
|
||||
Id: z.string().optional(),
|
||||
Name: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Contact info fields (derived from Contact lookup)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
ContactEmail: z.string().nullable().optional(), // Email
|
||||
ContactPhone: z.string().nullable().optional(), // Phone
|
||||
ContactMobile: z.string().nullable().optional(), // Phone
|
||||
ContactFax: z.string().nullable().optional(), // Phone
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Timestamps
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
CreatedDate: z.string().nullable().optional(), // Date/Time Opened
|
||||
LastModifiedDate: z.string().nullable().optional(), // Derived from LastModifiedById
|
||||
ClosedDate: z.string().nullable().optional(), // Date/Time Closed
|
||||
SlaStartDate: z.string().nullable().optional(), // SLA Policy Start Time
|
||||
SlaExitDate: z.string().nullable().optional(), // SLA Policy End Time
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Boolean flags
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
IsEscalated: z.boolean().nullable().optional(), // Escalated checkbox
|
||||
IsClosedOnCreate: z.boolean().nullable().optional(), // Closed When Created
|
||||
IsStopped: z.boolean().nullable().optional(), // Stopped checkbox
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Other standard fields
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Comments: z.string().nullable().optional(), // Internal Comments - Text Area(4000)
|
||||
MilestoneStatus: z.string().nullable().optional(), // Text(30)
|
||||
Language: z.string().nullable().optional(), // Picklist
|
||||
|
||||
// Web-to-Case fields
|
||||
SuppliedName: z.string().nullable().optional(), // Web Name - Text(80)
|
||||
SuppliedEmail: z.string().nullable().optional(), // Web Email - Email
|
||||
SuppliedPhone: z.string().nullable().optional(), // Web Phone - Text(40)
|
||||
SuppliedCompany: z.string().nullable().optional(), // Web Company - Text(80)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Custom fields (org-specific)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// AI-related fields
|
||||
Case_Concern_AI__c: z.string().nullable().optional(), // Text Area(255)
|
||||
Case_Email_Summary__c: z.string().nullable().optional(), // Long Text Area(32768)
|
||||
Summary_AI__c: z.string().nullable().optional(), // Long Text Area(32768)
|
||||
Resolution_AI__c: z.string().nullable().optional(), // Long Text Area(32768)
|
||||
|
||||
// Other custom fields (add as needed)
|
||||
Department__c: z.string().nullable().optional(), // Picklist
|
||||
Comment__c: z.string().nullable().optional(), // Long Text Area(32768)
|
||||
Notes__c: z.string().nullable().optional(), // Long Text Area(32768)
|
||||
});
|
||||
|
||||
export type SalesforceCaseRecord = z.infer<typeof salesforceCaseRecordSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Salesforce Case Create Payload
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Payload schema for creating a new Case in Salesforce
|
||||
*
|
||||
* Uses Salesforce API names (Japanese) for Status and Priority defaults.
|
||||
*/
|
||||
export const salesforceCaseCreatePayloadSchema = z.object({
|
||||
Subject: z.string(),
|
||||
Description: z.string(),
|
||||
Status: z.string().default("新規"), // Default: New (Japanese API name)
|
||||
Priority: z.string().default("中"), // Default: Medium (Japanese API name)
|
||||
Origin: z.string(),
|
||||
Type: z.string().optional(),
|
||||
AccountId: z.string(),
|
||||
ContactId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SalesforceCaseCreatePayload = z.infer<typeof salesforceCaseCreatePayloadSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Salesforce Case Field Constants (Org-Specific Picklist Values)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Salesforce Case Origin picklist values
|
||||
* API Names from Salesforce Setup > Object Manager > Case > Fields > Case Origin
|
||||
*/
|
||||
export const SALESFORCE_CASE_ORIGIN = {
|
||||
// Portal origin (used for portal-created cases)
|
||||
PORTAL_WEBSITE: "Portal Website",
|
||||
|
||||
// Phone/Email origins
|
||||
PHONE: "電話", // Japanese: Phone
|
||||
MAIL: "メール", // Japanese: Mail
|
||||
EMAIL: "Email",
|
||||
WEB: "Web",
|
||||
|
||||
// ChatBot origins
|
||||
AS_CHATBOT: "AS ChatBot",
|
||||
GENKI_CHATBOT: "Genki ChatBot",
|
||||
|
||||
// Messaging origins
|
||||
WHATSAPP_CHAT: "WhatsApp Chat",
|
||||
MESSENGER_CHAT: "Messenger Chat",
|
||||
|
||||
// Mail-specific origins
|
||||
GENKI_MAIL: "Genki Mail",
|
||||
BILLING_MAIL: "Billing Mail",
|
||||
TECH_MAIL: "Tech Mail",
|
||||
HELPDESK_MAIL: "Helpdesk Mail",
|
||||
MESERATI_MAIL: "Meserati Mail",
|
||||
|
||||
// Other
|
||||
WEB_URGENT_INQUIRY: "Web Urgent Inquiry",
|
||||
} as const;
|
||||
|
||||
export type SalesforceCaseOrigin =
|
||||
(typeof SALESFORCE_CASE_ORIGIN)[keyof typeof SALESFORCE_CASE_ORIGIN];
|
||||
|
||||
/**
|
||||
* Salesforce Case Status picklist values
|
||||
* API Names from Salesforce Setup > Object Manager > Case > Fields > Status
|
||||
*
|
||||
* Closed statuses: VPN Pending, Pending, 完了済み (Replied)
|
||||
* Open statuses: 新規 (New), 対応中 (Reply in Progress), Awaiting Approval
|
||||
*/
|
||||
export const SALESFORCE_CASE_STATUS = {
|
||||
// Open statuses
|
||||
NEW: "新規", // Japanese: New Email (Default)
|
||||
IN_PROGRESS: "対応中", // Japanese: Reply in Progress
|
||||
AWAITING_APPROVAL: "Awaiting Approval",
|
||||
|
||||
// Closed statuses
|
||||
VPN_PENDING: "VPN Pending", // Closed
|
||||
PENDING: "Pending", // Closed
|
||||
REPLIED: "完了済み", // Japanese: Replied/Completed (Closed)
|
||||
CLOSED: "Closed",
|
||||
} as const;
|
||||
|
||||
export type SalesforceCaseStatus =
|
||||
(typeof SALESFORCE_CASE_STATUS)[keyof typeof SALESFORCE_CASE_STATUS];
|
||||
|
||||
/** Status values that indicate a case is closed */
|
||||
export const SALESFORCE_CLOSED_STATUSES: SalesforceCaseStatus[] = [
|
||||
SALESFORCE_CASE_STATUS.VPN_PENDING,
|
||||
SALESFORCE_CASE_STATUS.PENDING,
|
||||
SALESFORCE_CASE_STATUS.REPLIED,
|
||||
SALESFORCE_CASE_STATUS.CLOSED,
|
||||
];
|
||||
|
||||
/** Status values that indicate a case is open */
|
||||
export const SALESFORCE_OPEN_STATUSES: SalesforceCaseStatus[] = [
|
||||
SALESFORCE_CASE_STATUS.NEW,
|
||||
SALESFORCE_CASE_STATUS.IN_PROGRESS,
|
||||
SALESFORCE_CASE_STATUS.AWAITING_APPROVAL,
|
||||
];
|
||||
|
||||
/**
|
||||
* Salesforce Case Priority picklist values
|
||||
* API Names from Salesforce Setup > Object Manager > Case > Fields > Priority
|
||||
*/
|
||||
export const SALESFORCE_CASE_PRIORITY = {
|
||||
HIGH: "高", // Japanese: High
|
||||
MEDIUM: "中", // Japanese: Medium (Default)
|
||||
LOW: "低", // Japanese: Low
|
||||
MEDIUM_EN: "Medium", // English Medium (also exists)
|
||||
} as const;
|
||||
|
||||
export type SalesforceCasePriority =
|
||||
(typeof SALESFORCE_CASE_PRIORITY)[keyof typeof SALESFORCE_CASE_PRIORITY];
|
||||
|
||||
// ============================================================================
|
||||
// Portal Display Labels (for UI rendering)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Map Salesforce status API names to portal display labels
|
||||
*/
|
||||
export const STATUS_DISPLAY_LABELS: Record<string, string> = {
|
||||
[SALESFORCE_CASE_STATUS.NEW]: "New",
|
||||
[SALESFORCE_CASE_STATUS.IN_PROGRESS]: "In Progress",
|
||||
[SALESFORCE_CASE_STATUS.AWAITING_APPROVAL]: "Awaiting Approval",
|
||||
[SALESFORCE_CASE_STATUS.VPN_PENDING]: "VPN Pending",
|
||||
[SALESFORCE_CASE_STATUS.PENDING]: "Pending",
|
||||
[SALESFORCE_CASE_STATUS.REPLIED]: "Resolved",
|
||||
[SALESFORCE_CASE_STATUS.CLOSED]: "Closed",
|
||||
};
|
||||
|
||||
/**
|
||||
* Map Salesforce priority API names to portal display labels
|
||||
*/
|
||||
export const PRIORITY_DISPLAY_LABELS: Record<string, string> = {
|
||||
[SALESFORCE_CASE_PRIORITY.HIGH]: "High",
|
||||
[SALESFORCE_CASE_PRIORITY.MEDIUM]: "Medium",
|
||||
[SALESFORCE_CASE_PRIORITY.LOW]: "Low",
|
||||
[SALESFORCE_CASE_PRIORITY.MEDIUM_EN]: "Medium",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get display label for a status value
|
||||
*/
|
||||
export function getStatusDisplayLabel(status: string): string {
|
||||
return STATUS_DISPLAY_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display label for a priority value
|
||||
*/
|
||||
export function getPriorityDisplayLabel(priority: string): string {
|
||||
return PRIORITY_DISPLAY_LABELS[priority] ?? priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status indicates the case is closed
|
||||
*/
|
||||
export function isClosedStatus(status: string): boolean {
|
||||
return SALESFORCE_CLOSED_STATUSES.includes(status as SalesforceCaseStatus);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reverse Mapping (Portal Display → Salesforce API)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Map portal display priority to Salesforce API priority value
|
||||
* Used when creating cases - converts "High" → "高", etc.
|
||||
*/
|
||||
const PRIORITY_TO_SALESFORCE: Record<string, string> = {
|
||||
High: SALESFORCE_CASE_PRIORITY.HIGH, // "高"
|
||||
Medium: SALESFORCE_CASE_PRIORITY.MEDIUM, // "中"
|
||||
Low: SALESFORCE_CASE_PRIORITY.LOW, // "低"
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert portal priority display value to Salesforce API value
|
||||
*/
|
||||
export function toSalesforcePriority(displayPriority: string): string {
|
||||
return PRIORITY_TO_SALESFORCE[displayPriority] ?? SALESFORCE_CASE_PRIORITY.MEDIUM;
|
||||
}
|
||||
|
||||
@ -5,19 +5,26 @@ import {
|
||||
SUPPORT_CASE_CATEGORY,
|
||||
} from "./contract";
|
||||
|
||||
/**
|
||||
* Portal status values (mapped from Salesforce Japanese API names)
|
||||
*/
|
||||
const supportCaseStatusValues = [
|
||||
SUPPORT_CASE_STATUS.OPEN,
|
||||
SUPPORT_CASE_STATUS.NEW,
|
||||
SUPPORT_CASE_STATUS.IN_PROGRESS,
|
||||
SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER,
|
||||
SUPPORT_CASE_STATUS.AWAITING_APPROVAL,
|
||||
SUPPORT_CASE_STATUS.VPN_PENDING,
|
||||
SUPPORT_CASE_STATUS.PENDING,
|
||||
SUPPORT_CASE_STATUS.RESOLVED,
|
||||
SUPPORT_CASE_STATUS.CLOSED,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Portal priority values (mapped from Salesforce Japanese API names)
|
||||
*/
|
||||
const supportCasePriorityValues = [
|
||||
SUPPORT_CASE_PRIORITY.LOW,
|
||||
SUPPORT_CASE_PRIORITY.MEDIUM,
|
||||
SUPPORT_CASE_PRIORITY.HIGH,
|
||||
SUPPORT_CASE_PRIORITY.CRITICAL,
|
||||
] as const;
|
||||
|
||||
const supportCaseCategoryValues = [
|
||||
@ -31,17 +38,21 @@ export const supportCaseStatusSchema = z.enum(supportCaseStatusValues);
|
||||
export const supportCasePrioritySchema = z.enum(supportCasePriorityValues);
|
||||
export const supportCaseCategorySchema = z.enum(supportCaseCategoryValues);
|
||||
|
||||
/**
|
||||
* Support case schema - compatible with Salesforce Case object
|
||||
* ID is a string (Salesforce ID format: 15 or 18 char alphanumeric)
|
||||
*/
|
||||
export const supportCaseSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
id: z.string().min(15).max(18),
|
||||
caseNumber: z.string(),
|
||||
subject: z.string().min(1),
|
||||
status: supportCaseStatusSchema,
|
||||
priority: supportCasePrioritySchema,
|
||||
category: supportCaseCategorySchema,
|
||||
status: z.string(), // Allow any status from Salesforce
|
||||
priority: z.string(), // Allow any priority from Salesforce
|
||||
category: z.string().nullable(), // Maps to Salesforce Type field
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
lastReply: z.string().optional(),
|
||||
closedAt: z.string().nullable(),
|
||||
description: z.string(),
|
||||
assignedTo: z.string().optional(),
|
||||
});
|
||||
|
||||
export const supportCaseSummarySchema = z.object({
|
||||
@ -58,13 +69,31 @@ export const supportCaseListSchema = z.object({
|
||||
|
||||
export const supportCaseFilterSchema = z
|
||||
.object({
|
||||
status: supportCaseStatusSchema.optional(),
|
||||
priority: supportCasePrioritySchema.optional(),
|
||||
category: supportCaseCategorySchema.optional(),
|
||||
status: z.string().optional(),
|
||||
priority: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
search: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.default({});
|
||||
|
||||
/**
|
||||
* Request schema for creating a new support case
|
||||
*/
|
||||
export const createCaseRequestSchema = z.object({
|
||||
subject: z.string().min(1).max(255),
|
||||
description: z.string().min(1).max(32000),
|
||||
category: supportCaseCategorySchema.optional(),
|
||||
priority: supportCasePrioritySchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response schema for case creation
|
||||
*/
|
||||
export const createCaseResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
caseNumber: z.string(),
|
||||
});
|
||||
|
||||
export type SupportCaseStatus = z.infer<typeof supportCaseStatusSchema>;
|
||||
export type SupportCasePriority = z.infer<typeof supportCasePrioritySchema>;
|
||||
export type SupportCaseCategory = z.infer<typeof supportCaseCategorySchema>;
|
||||
@ -72,3 +101,5 @@ export type SupportCase = z.infer<typeof supportCaseSchema>;
|
||||
export type SupportCaseSummary = z.infer<typeof supportCaseSummarySchema>;
|
||||
export type SupportCaseList = z.infer<typeof supportCaseListSchema>;
|
||||
export type SupportCaseFilter = z.infer<typeof supportCaseFilterSchema>;
|
||||
export type CreateCaseRequest = z.infer<typeof createCaseRequestSchema>;
|
||||
export type CreateCaseResponse = z.infer<typeof createCaseResponseSchema>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user