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:
barsa 2025-11-26 16:36:06 +09:00
parent 46c2896935
commit c7230f391a
45 changed files with 3313 additions and 1242 deletions

View File

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

View File

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

View 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)}`;
}
}

View File

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

View File

@ -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,
],

View File

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

View File

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

View File

@ -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],

View File

@ -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;
}
}
private applyFilters(cases: SupportCase[], filters: SupportCaseFilter): SupportCase[] {
/**
* 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;
}
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;

View File

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

View File

@ -0,0 +1,12 @@
import { SupportHomeView } from "@/features/support";
import { AgentforceWidget } from "@/components";
export default function SupportPage() {
return (
<>
<SupportHomeView />
<AgentforceWidget />
</>
);
}

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export { AgentforceWidget } from "./AgentforceWidget";

View File

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

View File

@ -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" },
],
},
{

View File

@ -4,3 +4,4 @@
*/
export { AppShell } from "./AppShell/AppShell";
export { AgentforceWidget } from "./AgentforceWidget";

View File

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

View File

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

View File

@ -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
*/

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

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

View 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];
}

View File

@ -0,0 +1,2 @@
export * from "./case-presenters";

View File

@ -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));
try {
await createCaseMutation.mutateAsync({
subject: formData.subject.trim(),
description: formData.description.trim(),
priority: formData.priority,
});
// Redirect to cases list with success message
router.push("/support/cases?created=true");
} catch (error) {
logger.error("Error creating case", error);
} finally {
setIsSubmitting(false);
}
})();
router.push("/support/cases?created=true");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create support case");
}
};
const handleInputChange = (field: string, value: string) => {
@ -50,198 +56,194 @@ export function NewSupportCaseView() {
const isFormValid = formData.subject.trim() && formData.description.trim();
const priorityOptions = [
{ value: SUPPORT_CASE_PRIORITY.LOW, label: "Low - General question" },
{ value: SUPPORT_CASE_PRIORITY.MEDIUM, label: "Medium - Issue affecting work" },
{ value: SUPPORT_CASE_PRIORITY.HIGH, label: "High - Urgent / Service disruption" },
];
return (
<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"
>
<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">
<PageLayout
icon={<TicketIconSolid />}
title="Create Support Case"
description="Get help from our support team"
breadcrumbs={[
{ label: "Support", href: "/support" },
{ label: "Create Case" },
]}
>
{/* 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&apos;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>
{/* Form */}
<div className="bg-white shadow rounded-lg">
<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>
<input
type="text"
id="subject"
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"
required
/>
</div>
{/* Error Message */}
{error && (
<AlertBanner variant="error" title="Error creating case" elevated>
{error}
</AlertBanner>
)}
{/* Category and Priority */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<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">
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"
>
<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>
</select>
</div>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
Description *
</label>
<textarea
id="description"
rows={6}
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"
required
/>
<p className="mt-2 text-xs text-gray-500">
The more details you provide, the faster we can help you.
</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&apos;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"
>
Cancel
</button>
<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"
>
{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>
</div>
</form>
</div>
{/* 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>
</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>
</p>
</div>
{/* Form */}
<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-900 mb-2">
Subject <span className="text-red-500">*</span>
</label>
<input
type="text"
id="subject"
value={formData.subject}
onChange={event => handleInputChange("subject", event.target.value)}
placeholder="Brief description of your issue"
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>
{/* Priority */}
<div>
<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 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"
>
{priorityOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Description */}
<div>
<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"
rows={6}
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-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">
The more details you provide, the faster we can help you.
</p>
</div>
{/* Actions */}
<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
</Link>
<Button
type="submit"
disabled={!isFormValid || createCaseMutation.isPending}
loading={createCaseMutation.isPending}
loadingText="Creating Case..."
leftIcon={<PaperAirplaneIcon className="h-4 w-4" />}
size="lg"
>
Create Case
</Button>
</div>
</form>
</AnimatedCard>
{/* 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>
</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>
</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>
</AnimatedCard>
</PageLayout>
);
}

View 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&apos;re looking for could not be found or you don&apos;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>
);
}

View File

@ -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,302 +76,186 @@ 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
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" />
New Case
</Link>
</div>
</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"}
</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>
</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 */}
<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" />
</div>
<input
type="text"
placeholder="Search cases..."
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"
/>
</div>
{/* Status Filter */}
<div className="relative">
<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"
>
{statusFilterOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</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"
>
{priorityFilterOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
</div>
{/* Cases List */}
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{cases.map(supportCase => (
<li key={supportCase.id}>
<Link
href={`/support/cases/${supportCase.id}`}
className="block hover:bg-gray-50"
>
<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>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(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)}`}
>
{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>
</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"}
</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>
)}
</div>
)}
<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"
leftIcon={<TicketIcon className="h-4 w-4" />}
>
New Case
</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>
{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>
)}
<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>
</>
{/* 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-4 w-4 text-gray-400" />
</div>
<input
type="text"
placeholder="Search by case number or subject..."
value={searchTerm}
onChange={event => setSearchTerm(event.target.value)}
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>
{/* Filters */}
<div className="flex gap-2">
<select
value={statusFilter}
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}>
{option.label}
</option>
))}
</select>
<select
value={priorityFilter}
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}>
{option.label}
</option>
))}
</select>
{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 */}
{cases.length > 0 ? (
<div className="border border-gray-200 rounded-xl bg-white divide-y divide-gray-100 overflow-hidden">
{cases.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"
>
{/* 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 text-xs px-2 py-0.5 rounded font-medium ${getCaseStatusClasses(supportCase.status)}`}
>
{supportCase.status}
</span>
<span
className={`inline-flex text-xs px-2 py-0.5 rounded font-medium ${getCasePriorityClasses(supportCase.priority)}`}
>
{supportCase.priority}
</span>
{supportCase.category && (
<span className="text-xs text-gray-500">
{supportCase.category}
</span>
)}
</div>
</div>
{/* 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>
</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>
)}
</PageLayout>
);
}

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

View File

@ -1,2 +1,4 @@
export * from "./NewSupportCaseView";
export * from "./SupportCasesView";
export * from "./SupportCaseDetailView";
export * from "./SupportHomeView";

View File

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

View File

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

View File

@ -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
// ============================================================================
/**
* Extract error information from various error types
* Parse any error into a structured format with code and user-friendly message.
* This is the main entry point for error handling.
*/
export function getErrorInfo(error: unknown): ApiErrorInfo {
if (error instanceof ClientApiError) {
const info = parseClientApiError(error);
if (info) {
return info;
}
export function parseError(error: unknown): ParsedError {
// Handle API client errors
if (isApiError(error)) {
return parseApiError(error);
}
// Handle fetch/network errors
// Handle network/fetch errors
if (error instanceof Error) {
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.",
shouldRetry: true,
};
}
if (error.name === "AbortError") {
return {
code: "REQUEST_TIMEOUT",
message: "The request timed out. Please try again.",
shouldRetry: true,
};
}
return parseNativeError(error);
}
// Handle string errors
if (typeof error === "string") {
const code = matchErrorPattern(error);
const metadata = ErrorMetadata[code];
return {
code: "UNKNOWN_ERROR",
message: "An unexpected error occurred. Please try again.",
shouldRetry: true,
code,
message: error,
shouldLogout: metadata.shouldLogout,
shouldRetry: metadata.shouldRetry,
};
}
// Fallback for unknown error types
// Unknown error type
return {
code: "UNKNOWN_ERROR",
message: "An unexpected error occurred. Please try again.",
code: ErrorCode.UNKNOWN,
message: ErrorMessages[ErrorCode.UNKNOWN],
shouldLogout: false,
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
* Parse API client error
*/
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 {
function parseApiError(error: ClientApiError): ParsedError {
const body = error.body;
const status = error.response?.status;
const parsedBody = parseRawErrorBody(error.body);
const payloadInfo = parsedBody ? deriveInfoFromPayload(parsedBody, status) : null;
// Try to extract from standard API error response format
if (body && typeof body === "object") {
const bodyObj = body as Record<string, unknown>;
if (payloadInfo) {
return payloadInfo;
}
// 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;
return {
code: status ? httpStatusCodeToLabel(status) : "API_ERROR",
message: error.message,
shouldLogout: status ? shouldLogoutForError(httpStatusCodeToLabel(status)) : false,
shouldRetry: typeof status === "number" ? status >= 500 : true,
};
}
if (code && message) {
const metadata = ErrorMetadata[code as ErrorCodeType] ?? ErrorMetadata[ErrorCode.UNKNOWN];
return {
code: code as ErrorCodeType,
message,
shouldLogout: metadata.shouldLogout,
shouldRetry: metadata.shouldRetry,
};
}
}
function parseRawErrorBody(body: unknown): unknown {
if (!body) {
return null;
}
if (typeof body === "string") {
try {
return JSON.parse(body);
} catch {
return body;
// 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,
};
}
}
return body;
// 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,
};
}
function deriveInfoFromPayload(payload: unknown, status?: number): ApiErrorInfo | null {
const parsed = apiErrorResponseSchema.safeParse(payload);
if (parsed.success) {
const code = parsed.data.error.code;
/**
* Parse native JavaScript errors (network, timeout, etc.)
*/
function parseNativeError(error: Error): ParsedError {
// Network errors
if (error.name === "TypeError" && error.message.includes("fetch")) {
return {
code,
message: parsed.data.error.message,
shouldLogout: shouldLogoutForError(code),
shouldRetry: shouldRetryForError(code),
code: ErrorCode.NETWORK_ERROR,
message: ErrorMessages[ErrorCode.NETWORK_ERROR],
shouldLogout: false,
shouldRetry: true,
};
}
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;
// Timeout errors
if (error.name === "AbortError") {
return {
code,
message,
shouldLogout: shouldLogoutForError(code),
shouldRetry:
typeof derivedStatus === "number" ? derivedStatus >= 500 : shouldRetryForError(code),
code: ErrorCode.TIMEOUT,
message: ErrorMessages[ErrorCode.TIMEOUT],
shouldLogout: false,
shouldRetry: true,
};
}
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);
// Try pattern matching on error message
const code = matchErrorPattern(error.message);
const metadata = ErrorMetadata[code];
return {
code,
message: code === ErrorCode.UNKNOWN ? error.message : ErrorMessages[code],
shouldLogout: metadata.shouldLogout,
shouldRetry: metadata.shouldRetry,
};
}
return {
code,
message: payloadWithMessage.message,
shouldLogout: shouldLogoutForError(code),
shouldRetry: typeof status === "number" ? status >= 500 : shouldRetryForError(code),
};
/**
* Extract error message from response body
*/
function extractMessageFromBody(body: unknown): string | null {
if (!body || typeof body !== "object") {
return null;
}
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;
}
}
// 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";

View File

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

View File

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

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

View File

@ -1,12 +1,13 @@
/**
* Common Domain
*
*
* Shared types and utilities used across all domains.
*/
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";

View File

@ -4,3 +4,4 @@
export * from "./contract";
export * from "./schema";
export * from "./utils";

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

View File

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

View File

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

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

View File

@ -0,0 +1,7 @@
/**
* Support Domain - Salesforce Provider
*/
export * from "./raw.types";
export * from "./mapper";

View 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();
}

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

View File

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