refactor: fix all lint errors and reduce warnings across BFF and domain

Eliminate all 12 ESLint errors (nested ternaries, any types) and reduce
warnings by 13 (duplicate strings, complexity). Key changes:

- Domain: extract helpers for nested ternaries in opportunity/contract and whmcs/mapper
- BFF core: fix any type in safe-operation.util, refactor exception filter to use
  options objects, create shared CACHE_CONTROL and normalizeToArray utilities
- Freebit: replace nested ternaries with if/else in client and mapper services
- Sim fulfillment: extract helper methods to reduce complexity (fulfillEsim,
  fulfillPhysicalSim, buildMnpPayload, registerVoiceOptionsIfAvailable, MNP_FIELD_MAPPINGS)
- Modules: fix 8 nested ternary violations across validators, services, controllers
- Constants: extract duplicate strings (CSRF, email, orchestrator, cache control)
This commit is contained in:
barsa 2026-03-04 10:52:26 +09:00
parent 953878e6c6
commit 26776373f7
28 changed files with 470 additions and 377 deletions

View File

@ -0,0 +1,13 @@
/**
* Shared HTTP-related constants for controller responses.
*/
/** Standard Cache-Control header values */
export const CACHE_CONTROL = {
/** Public resources cached for 1 hour */
PUBLIC_1H: "public, max-age=3600",
/** Private (per-user) resources cached for 5 minutes */
PRIVATE_5M: "private, max-age=300",
/** Private (per-user) resources cached for 1 minute */
PRIVATE_1M: "private, max-age=60",
} as const;

View File

@ -0,0 +1 @@
export * from "./http.constants.js";

View File

@ -86,16 +86,16 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
const userMessage = this.getUserMessage(errorCode, originalMessage, fieldErrors); const userMessage = this.getUserMessage(errorCode, originalMessage, fieldErrors);
// Log the error // Log the error
this.logError(errorCode, originalMessage, status, errorContext, exception); this.logError({ errorCode, originalMessage, status, context: errorContext, exception });
// Build and send response // Build and send response
const errorResponse = this.buildErrorResponse( const errorResponse = this.buildErrorResponse({
errorCode, errorCode,
userMessage, message: userMessage,
status, status,
errorContext, context: errorContext,
fieldErrors fieldErrors,
); });
response.status(status).json(errorResponse); response.status(status).json(errorResponse);
} }
@ -298,34 +298,36 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
private buildErrorContext( private buildErrorContext(
request: Request & { user?: { id?: string }; requestId?: string } request: Request & { user?: { id?: string }; requestId?: string }
): ErrorContext { ): ErrorContext {
const userAgentHeader = request.headers["user-agent"];
const userAgent =
typeof userAgentHeader === "string"
? userAgentHeader
: Array.isArray(userAgentHeader)
? userAgentHeader[0]
: undefined;
return { return {
requestId: request.requestId ?? this.generateRequestId(), requestId: request.requestId ?? this.generateRequestId(),
userId: request.user?.id, userId: request.user?.id,
method: request.method, method: request.method,
path: request.url, path: request.url,
userAgent, userAgent: this.extractUserAgent(request.headers["user-agent"]),
ip: request.ip, ip: request.ip,
}; };
} }
/**
* Extract user-agent string from request header
*/
private extractUserAgent(header: string | string[] | undefined): string | undefined {
if (typeof header === "string") return header;
if (Array.isArray(header)) return header[0];
return undefined;
}
/** /**
* Log error with appropriate level based on metadata * Log error with appropriate level based on metadata
*/ */
private logError( private logError(params: {
errorCode: ErrorCodeType, errorCode: ErrorCodeType;
originalMessage: string, originalMessage: string;
status: number, status: number;
context: ErrorContext, context: ErrorContext;
exception: unknown exception: unknown;
): void { }): void {
const { errorCode, originalMessage, status, context, exception } = params;
const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN]; const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN];
const logData = { const logData = {
@ -370,13 +372,14 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
/** /**
* Build standard error response * Build standard error response
*/ */
private buildErrorResponse( private buildErrorResponse(params: {
errorCode: ErrorCodeType, errorCode: ErrorCodeType;
message: string, message: string;
status: number, status: number;
context: ErrorContext, context: ErrorContext;
fieldErrors?: Record<string, string> fieldErrors?: Record<string, string> | undefined;
): ApiError { }): ApiError {
const { errorCode, message, status, context, fieldErrors } = params;
const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN]; const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN];
return { return {

View File

@ -12,6 +12,8 @@ export type AuthenticatedRequest = Request & {
sessionID?: string; sessionID?: string;
}; };
const USER_AGENT_HEADER = "user-agent";
@Controller("security/csrf") @Controller("security/csrf")
export class CsrfController { export class CsrfController {
constructor( constructor(
@ -23,69 +25,13 @@ export class CsrfController {
@Public() @Public()
@Get("token") @Get("token")
getCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) { getCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
const sessionId = this.extractSessionId(req) || undefined; return this.generateAndSetToken(req, res, "CSRF token requested");
const userId = req.user?.id;
// Generate new CSRF token
const tokenData = this.csrfService.generateToken(undefined, sessionId, userId);
const isProduction = this.configService.get("NODE_ENV") === "production";
const cookieName = this.csrfService.getCookieName();
// Set CSRF secret in secure cookie
res.cookie(cookieName, tokenData.secret, {
httpOnly: true,
secure: isProduction,
sameSite: "strict",
maxAge: this.csrfService.getTokenTtl(),
path: "/api",
});
this.logger.debug("CSRF token requested", {
userId,
sessionId,
userAgent: req.get("user-agent"),
ip: req.ip,
});
return res.json({
success: true,
token: tokenData.token,
expiresAt: tokenData.expiresAt.toISOString(),
});
} }
@Public() @Public()
@Post("refresh") @Post("refresh")
refreshCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) { refreshCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
const sessionId = this.extractSessionId(req) || undefined; return this.generateAndSetToken(req, res, "CSRF token refreshed");
const userId = req.user?.id;
// Generate new CSRF token
const tokenData = this.csrfService.generateToken(undefined, sessionId, userId);
const isProduction = this.configService.get("NODE_ENV") === "production";
const cookieName = this.csrfService.getCookieName();
// Set CSRF secret in secure cookie
res.cookie(cookieName, tokenData.secret, {
httpOnly: true,
secure: isProduction,
sameSite: "strict",
maxAge: this.csrfService.getTokenTtl(),
path: "/api",
});
this.logger.debug("CSRF token refreshed", {
userId,
sessionId,
userAgent: req.get("user-agent"),
ip: req.ip,
});
return res.json({
success: true,
token: tokenData.token,
expiresAt: tokenData.expiresAt.toISOString(),
});
} }
@UseGuards(AdminGuard) @UseGuards(AdminGuard)
@ -95,7 +41,7 @@ export class CsrfController {
this.logger.debug("CSRF stats requested", { this.logger.debug("CSRF stats requested", {
userId, userId,
userAgent: req.get("user-agent"), userAgent: req.get(USER_AGENT_HEADER),
ip: req.ip, ip: req.ip,
}); });
@ -107,6 +53,36 @@ export class CsrfController {
}; };
} }
private generateAndSetToken(req: AuthenticatedRequest, res: Response, logMessage: string) {
const sessionId = this.extractSessionId(req) || undefined;
const userId = req.user?.id;
const tokenData = this.csrfService.generateToken(undefined, sessionId, userId);
const isProduction = this.configService.get("NODE_ENV") === "production";
const cookieName = this.csrfService.getCookieName();
res.cookie(cookieName, tokenData.secret, {
httpOnly: true,
secure: isProduction,
sameSite: "strict",
maxAge: this.csrfService.getTokenTtl(),
path: "/api",
});
this.logger.debug(logMessage, {
userId,
sessionId,
userAgent: req.get(USER_AGENT_HEADER),
ip: req.ip,
});
return res.json({
success: true,
token: tokenData.token,
expiresAt: tokenData.expiresAt.toISOString(),
});
}
private extractSessionId(req: AuthenticatedRequest): string | null { private extractSessionId(req: AuthenticatedRequest): string | null {
const cookies = req.cookies as Record<string, string | undefined> | undefined; const cookies = req.cookies as Record<string, string | undefined> | undefined;
const sessionCookie = cookies?.["session-id"] ?? cookies?.["connect.sid"]; const sessionCookie = cookies?.["session-id"] ?? cookies?.["connect.sid"];

View File

@ -23,6 +23,8 @@ type CsrfRequest = Omit<BaseExpressRequest, "cookies"> & {
cookies: CookieJar; cookies: CookieJar;
}; };
const USER_AGENT_HEADER = "user-agent";
/** /**
* CSRF Protection Middleware * CSRF Protection Middleware
* Implements a double-submit cookie pattern: * Implements a double-submit cookie pattern:
@ -101,7 +103,7 @@ export class CsrfMiddleware implements NestMiddleware {
this.logger.warn("CSRF validation failed - missing token", { this.logger.warn("CSRF validation failed - missing token", {
method: req.method, method: req.method,
path: req.path, path: req.path,
userAgent: req.get("user-agent"), userAgent: req.get(USER_AGENT_HEADER),
ip: req.ip, ip: req.ip,
}); });
throw new ForbiddenException("CSRF token required"); throw new ForbiddenException("CSRF token required");
@ -111,7 +113,7 @@ export class CsrfMiddleware implements NestMiddleware {
this.logger.warn("CSRF validation failed - missing secret cookie", { this.logger.warn("CSRF validation failed - missing secret cookie", {
method: req.method, method: req.method,
path: req.path, path: req.path,
userAgent: req.get("user-agent"), userAgent: req.get(USER_AGENT_HEADER),
ip: req.ip, ip: req.ip,
}); });
throw new ForbiddenException("CSRF secret required"); throw new ForbiddenException("CSRF secret required");
@ -129,7 +131,7 @@ export class CsrfMiddleware implements NestMiddleware {
reason: validationResult.reason, reason: validationResult.reason,
method: req.method, method: req.method,
path: req.path, path: req.path,
userAgent: req.get("user-agent"), userAgent: req.get(USER_AGENT_HEADER),
ip: req.ip, ip: req.ip,
userId, userId,
sessionId, sessionId,

View File

@ -65,3 +65,12 @@ export function uniqueBy<T, K>(array: T[], keyFn: (item: T) => K): T[] {
return true; return true;
}); });
} }
/**
* Normalize a value that may be a single item, an array, or nullish into an array.
* Handles the common WHMCS pattern where API responses return either a single object or an array.
*/
export function normalizeToArray<T>(value: T | T[] | undefined | null): T[] {
if (Array.isArray(value)) return value;
return value ? [value] : [];
}

View File

@ -5,11 +5,16 @@
*/ */
// Array utilities // Array utilities
export { chunkArray, groupBy, uniqueBy } from "./array.util.js"; export { chunkArray, groupBy, uniqueBy, normalizeToArray } from "./array.util.js";
// Error utilities // Error utilities
export { extractErrorMessage } from "./error.util.js"; export { extractErrorMessage } from "./error.util.js";
export { safeOperation, OperationCriticality, type SafeOperationOptions, type SafeOperationResult } from "./safe-operation.util.js"; export {
safeOperation,
OperationCriticality,
type SafeOperationOptions,
type SafeOperationResult,
} from "./safe-operation.util.js";
// Retry utilities // Retry utilities
export { export {

View File

@ -39,7 +39,7 @@ export interface SafeOperationOptions<T> {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
/** Exception types to rethrow as-is (only applicable when criticality is CRITICAL) */ /** Exception types to rethrow as-is (only applicable when criticality is CRITICAL) */
rethrow?: Array<new (...args: any[]) => Error>; rethrow?: Array<abstract new (...args: never[]) => Error>;
/** Custom message for InternalServerErrorException wrapper (only applicable when criticality is CRITICAL) */ /** Custom message for InternalServerErrorException wrapper (only applicable when criticality is CRITICAL) */
fallbackMessage?: string; fallbackMessage?: string;
@ -89,7 +89,15 @@ export async function safeOperation<T>(
executor: () => Promise<T>, executor: () => Promise<T>,
options: SafeOperationOptions<T> options: SafeOperationOptions<T>
): Promise<T> { ): Promise<T> {
const { criticality, fallback, context, logger, metadata = {}, rethrow, fallbackMessage } = options; const {
criticality,
fallback,
context,
logger,
metadata = {},
rethrow,
fallbackMessage,
} = options;
try { try {
return await executor(); return await executor();

View File

@ -34,6 +34,7 @@ export interface ParsedSendGridError {
/** HTTP status codes that indicate transient failures worth retrying */ /** HTTP status codes that indicate transient failures worth retrying */
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
const UNKNOWN_ERROR = "Unknown error";
@Injectable() @Injectable()
export class SendGridEmailProvider implements OnModuleInit { export class SendGridEmailProvider implements OnModuleInit {
@ -199,20 +200,20 @@ export class SendGridEmailProvider implements OnModuleInit {
private extractErrorsFromBody(body: unknown): SendGridErrorDetail[] { private extractErrorsFromBody(body: unknown): SendGridErrorDetail[] {
if (!body || typeof body !== "object") { if (!body || typeof body !== "object") {
return [{ message: "Unknown error" }]; return [{ message: UNKNOWN_ERROR }];
} }
// SendGrid returns { errors: [...] } in the body // SendGrid returns { errors: [...] } in the body
const bodyObj = body as { errors?: Array<{ message?: string; field?: string; help?: string }> }; const bodyObj = body as { errors?: Array<{ message?: string; field?: string; help?: string }> };
if (Array.isArray(bodyObj.errors)) { if (Array.isArray(bodyObj.errors)) {
return bodyObj.errors.map(e => ({ return bodyObj.errors.map(e => ({
message: e.message || "Unknown error", message: e.message || UNKNOWN_ERROR,
field: e.field, field: e.field,
help: e.help, help: e.help,
})); }));
} }
return [{ message: "Unknown error" }]; return [{ message: UNKNOWN_ERROR }];
} }
private isSendGridError(error: unknown): error is ResponseError { private isSendGridError(error: unknown): error is ResponseError {

View File

@ -14,6 +14,8 @@ interface ParsedSendGridError {
isRetryable: boolean; isRetryable: boolean;
} }
const SERVICE_NAME = "email-processor";
/** TTL for idempotency keys (24 hours) - prevents duplicate sends on delayed retries */ /** TTL for idempotency keys (24 hours) - prevents duplicate sends on delayed retries */
const IDEMPOTENCY_TTL_SECONDS = 86400; const IDEMPOTENCY_TTL_SECONDS = 86400;
const IDEMPOTENCY_PREFIX = "email:sent:"; const IDEMPOTENCY_PREFIX = "email:sent:";
@ -31,7 +33,7 @@ export class EmailProcessor extends WorkerHost {
async process(job: Job<EmailJobData>): Promise<void> { async process(job: Job<EmailJobData>): Promise<void> {
const jobContext = { const jobContext = {
service: "email-processor", service: SERVICE_NAME,
jobId: job.id, jobId: job.id,
attempt: job.attemptsMade + 1, attempt: job.attemptsMade + 1,
maxAttempts: job.opts.attempts || 3, maxAttempts: job.opts.attempts || 3,
@ -88,7 +90,7 @@ export class EmailProcessor extends WorkerHost {
@OnWorkerEvent("completed") @OnWorkerEvent("completed")
onCompleted(job: Job<EmailJobData>): void { onCompleted(job: Job<EmailJobData>): void {
this.logger.debug("Email job marked complete", { this.logger.debug("Email job marked complete", {
service: "email-processor", service: SERVICE_NAME,
jobId: job.id, jobId: job.id,
category: job.data.category, category: job.data.category,
}); });
@ -98,7 +100,7 @@ export class EmailProcessor extends WorkerHost {
onFailed(job: Job<EmailJobData> | undefined, error: Error): void { onFailed(job: Job<EmailJobData> | undefined, error: Error): void {
if (!job) { if (!job) {
this.logger.error("Email job failed (job undefined)", { this.logger.error("Email job failed (job undefined)", {
service: "email-processor", service: SERVICE_NAME,
error: error.message, error: error.message,
}); });
return; return;
@ -109,7 +111,7 @@ export class EmailProcessor extends WorkerHost {
if (isLastAttempt) { if (isLastAttempt) {
this.logger.error("Email job permanently failed - all retries exhausted", { this.logger.error("Email job permanently failed - all retries exhausted", {
service: "email-processor", service: SERVICE_NAME,
jobId: job.id, jobId: job.id,
category: job.data.category, category: job.data.category,
subject: job.data.subject, subject: job.data.subject,
@ -119,7 +121,7 @@ export class EmailProcessor extends WorkerHost {
}); });
} else { } else {
this.logger.warn("Email job failed - will retry", { this.logger.warn("Email job failed - will retry", {
service: "email-processor", service: SERVICE_NAME,
jobId: job.id, jobId: job.id,
category: job.data.category, category: job.data.category,
attempt: job.attemptsMade, attempt: job.attemptsMade,
@ -133,7 +135,7 @@ export class EmailProcessor extends WorkerHost {
@OnWorkerEvent("error") @OnWorkerEvent("error")
onError(error: Error): void { onError(error: Error): void {
this.logger.error("Email processor worker error", { this.logger.error("Email processor worker error", {
service: "email-processor", service: SERVICE_NAME,
error: error.message, error: error.message,
stack: error.stack, stack: error.stack,
}); });

View File

@ -14,6 +14,8 @@ export interface EmailJobResult {
queued: boolean; queued: boolean;
} }
const SERVICE_NAME = "email-queue";
/** Queue configuration constants */ /** Queue configuration constants */
const QUEUE_CONFIG = { const QUEUE_CONFIG = {
/** Keep last N completed jobs for debugging */ /** Keep last N completed jobs for debugging */
@ -35,7 +37,7 @@ export class EmailQueueService {
async enqueueEmail(data: EmailJobData): Promise<EmailJobResult> { async enqueueEmail(data: EmailJobData): Promise<EmailJobResult> {
const jobContext = { const jobContext = {
service: "email-queue", service: SERVICE_NAME,
queue: QUEUE_NAMES.EMAIL, queue: QUEUE_NAMES.EMAIL,
category: data.category || "transactional", category: data.category || "transactional",
recipientCount: Array.isArray(data.to) ? data.to.length : 1, recipientCount: Array.isArray(data.to) ? data.to.length : 1,
@ -108,7 +110,7 @@ export class EmailQueueService {
const job = await this.queue.getJob(jobId); const job = await this.queue.getJob(jobId);
if (!job) { if (!job) {
this.logger.warn("Job not found for retry", { this.logger.warn("Job not found for retry", {
service: "email-queue", service: SERVICE_NAME,
jobId, jobId,
}); });
return; return;
@ -116,7 +118,7 @@ export class EmailQueueService {
await job.retry(); await job.retry();
this.logger.log("Job retry triggered", { this.logger.log("Job retry triggered", {
service: "email-queue", service: SERVICE_NAME,
jobId, jobId,
category: job.data.category, category: job.data.category,
}); });

View File

@ -255,19 +255,26 @@ export class FreebitClientService {
} }
const timestamp = this.testTracker.getCurrentTimestamp(); const timestamp = this.testTracker.getCurrentTimestamp();
const resultCode = response?.resultCode
? String(response.resultCode)
: error instanceof FreebitError
? String(error.resultCode || "ERROR")
: "ERROR";
const statusMessage = let resultCode: string;
response?.status?.message || if (response?.resultCode) {
(error instanceof FreebitError resultCode = String(response.resultCode);
? error.message } else if (error instanceof FreebitError) {
: error resultCode = String(error.resultCode || "ERROR");
? extractErrorMessage(error) } else {
: "Success"); resultCode = "ERROR";
}
let statusMessage: string;
if (response?.status?.message) {
statusMessage = response.status.message;
} else if (error instanceof FreebitError) {
statusMessage = error.message;
} else if (error) {
statusMessage = extractErrorMessage(error);
} else {
statusMessage = "Success";
}
await this.testTracker.logApiCall({ await this.testTracker.logApiCall({
timestamp, timestamp,

View File

@ -11,6 +11,8 @@ import type {
} from "../interfaces/freebit.types.js"; } from "../interfaces/freebit.types.js";
import type { VoiceOptionsService } from "@bff/modules/voice-options/services/voice-options.service.js"; import type { VoiceOptionsService } from "@bff/modules/voice-options/services/voice-options.service.js";
const NOT_IN_API_RESPONSE = "(not in API response)";
@Injectable() @Injectable()
export class FreebitMapperService { export class FreebitMapperService {
constructor( constructor(
@ -143,9 +145,9 @@ export class FreebitMapperService {
"[FreebitMapper] No stored options found, using API values (default: disabled)", "[FreebitMapper] No stored options found, using API values (default: disabled)",
{ {
account: account.account, account: account.account,
rawVoiceMail: account.voicemail ?? account.voiceMail ?? "(not in API response)", rawVoiceMail: account.voicemail ?? account.voiceMail ?? NOT_IN_API_RESPONSE,
rawCallWaiting: account.callwaiting ?? account.callWaiting ?? "(not in API response)", rawCallWaiting: account.callwaiting ?? account.callWaiting ?? NOT_IN_API_RESPONSE,
rawWorldWing: account.worldwing ?? account.worldWing ?? "(not in API response)", rawWorldWing: account.worldwing ?? account.worldWing ?? NOT_IN_API_RESPONSE,
parsedVoiceMailEnabled: voiceMailEnabled, parsedVoiceMailEnabled: voiceMailEnabled,
parsedCallWaitingEnabled: callWaitingEnabled, parsedCallWaitingEnabled: callWaitingEnabled,
parsedInternationalRoamingEnabled: internationalRoamingEnabled, parsedInternationalRoamingEnabled: internationalRoamingEnabled,

View File

@ -362,11 +362,14 @@ export class AccountCreationWorkflowService {
}); });
// Step 6: Generate Auth Result // Step 6: Generate Auth Result
const auditSource = withEligibility let auditSource: string;
? "signup_with_eligibility" if (withEligibility) {
: isNewCustomer auditSource = "signup_with_eligibility";
? "get_started_new_customer" } else if (isNewCustomer) {
: "get_started_complete_account"; auditSource = "get_started_new_customer";
} else {
auditSource = "get_started_complete_account";
}
const authResult = await this.authResultStep.execute({ const authResult = await this.authResultStep.execute({
userId: portalUserResult.userId, userId: portalUserResult.userId,

View File

@ -43,8 +43,11 @@ export class CheckoutService {
*/ */
private summarizeSelectionsForLog(selections: OrderSelections): Record<string, unknown> { private summarizeSelectionsForLog(selections: OrderSelections): Record<string, unknown> {
const addons = this.collectAddonRefs(selections); const addons = this.collectAddonRefs(selections);
const normalizeBool = (value?: string) => const normalizeBool = (value?: string): boolean | undefined => {
value === "true" ? true : value === "false" ? false : undefined; if (value === "true") return true;
if (value === "false") return false;
return undefined;
};
return { return {
planSku: selections.planSku, planSku: selections.planSku,

View File

@ -19,6 +19,9 @@ import type {
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
const SF_ACCOUNT_ID_FIELD = "sfAccountId";
const ORDER_NOT_FOUND = "Order not found";
type OrderDetailsResponse = OrderDetails; type OrderDetailsResponse = OrderDetails;
type OrderSummaryResponse = OrderSummary; type OrderSummaryResponse = OrderSummary;
@ -214,18 +217,18 @@ export class OrderOrchestrator {
async getOrderForUser(orderId: string, userId: string): Promise<OrderDetailsResponse> { async getOrderForUser(orderId: string, userId: string): Promise<OrderDetailsResponse> {
const userMapping = await this.orderValidator.validateUserMapping(userId); const userMapping = await this.orderValidator.validateUserMapping(userId);
const sfAccountId = userMapping.sfAccountId const sfAccountId = userMapping.sfAccountId
? assertSalesforceId(userMapping.sfAccountId, "sfAccountId") ? assertSalesforceId(userMapping.sfAccountId, SF_ACCOUNT_ID_FIELD)
: null; : null;
if (!sfAccountId) { if (!sfAccountId) {
this.logger.warn({ userId }, "User mapping missing Salesforce account ID"); this.logger.warn({ userId }, "User mapping missing Salesforce account ID");
throw new NotFoundException("Order not found"); throw new NotFoundException(ORDER_NOT_FOUND);
} }
const safeOrderId = assertSalesforceId(orderId, "orderId"); const safeOrderId = assertSalesforceId(orderId, "orderId");
const order = await this.getOrder(safeOrderId); const order = await this.getOrder(safeOrderId);
if (!order) { if (!order) {
throw new NotFoundException("Order not found"); throw new NotFoundException(ORDER_NOT_FOUND);
} }
if (!order.accountId || order.accountId !== sfAccountId) { if (!order.accountId || order.accountId !== sfAccountId) {
@ -238,7 +241,7 @@ export class OrderOrchestrator {
}, },
"Order access denied due to account mismatch" "Order access denied due to account mismatch"
); );
throw new NotFoundException("Order not found"); throw new NotFoundException(ORDER_NOT_FOUND);
} }
return order; return order;
@ -253,7 +256,7 @@ export class OrderOrchestrator {
// Get user mapping // Get user mapping
const userMapping = await this.orderValidator.validateUserMapping(userId); const userMapping = await this.orderValidator.validateUserMapping(userId);
const sfAccountId = userMapping.sfAccountId const sfAccountId = userMapping.sfAccountId
? assertSalesforceId(userMapping.sfAccountId, "sfAccountId") ? assertSalesforceId(userMapping.sfAccountId, SF_ACCOUNT_ID_FIELD)
: undefined; : undefined;
if (!sfAccountId) { if (!sfAccountId) {

View File

@ -4,6 +4,7 @@ import { ZodError } from "zod";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js"; import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { normalizeToArray } from "@bff/core/utils/array.util.js";
import { import {
orderWithSkuValidationSchema, orderWithSkuValidationSchema,
type CreateOrderRequest, type CreateOrderRequest,
@ -82,11 +83,7 @@ export class OrderValidator {
try { try {
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId }); const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
const productContainer = products.products?.product; const productContainer = products.products?.product;
const existing = Array.isArray(productContainer) const existing = normalizeToArray(productContainer);
? productContainer
: productContainer
? [productContainer]
: [];
// Check for active Internet products // Check for active Internet products
const activeInternetProducts = existing.filter((product: WhmcsProduct) => { const activeInternetProducts = existing.filter((product: WhmcsProduct) => {

View File

@ -141,15 +141,15 @@ export class SimFulfillmentService {
const scheduledAt = this.readString(configurations["scheduledAt"]); const scheduledAt = this.readString(configurations["scheduledAt"]);
const phoneNumber = this.readString(configurations["mnpPhone"]); const phoneNumber = this.readString(configurations["mnpPhone"]);
const mnp = this.extractMnpConfig(configurations); const mnp = this.extractMnpConfig(configurations);
const isMnp = !!mnp?.reserveNumber; const isMnp = !!mnp?.["reserveNumber"];
this.logger.log("MNP detection result", { this.logger.log("MNP detection result", {
orderId: orderDetails.id, orderId: orderDetails.id,
isMnp, isMnp,
simType, simType,
mnpReserveNumber: mnp?.reserveNumber, mnpReserveNumber: mnp?.["reserveNumber"],
mnpHasIdentity: !!(mnp?.lastnameKanji || mnp?.firstnameKanji), mnpHasIdentity: !!(mnp?.["lastnameKanji"] || mnp?.["firstnameKanji"]),
mnpGender: mnp?.gender, mnpGender: mnp?.["gender"],
}); });
const simPlanItem = orderDetails.items.find( const simPlanItem = orderDetails.items.find(
@ -173,93 +173,152 @@ export class SimFulfillmentService {
} }
if (simType === "eSIM") { if (simType === "eSIM") {
// eSIM activation flow return this.fulfillEsim({
if (!eid || eid.length < 15) { orderDetails,
throw new SimActivationException("EID is required for eSIM and must be valid", {
orderId: orderDetails.id,
simType,
eidLength: eid?.length,
});
}
if (!phoneNumber) {
throw new SimActivationException("Phone number is required for eSIM activation", {
orderId: orderDetails.id,
});
}
// Map product SKU to Freebit plan code (same as physical SIM path)
const planCode = mapProductToFreebitPlanCode(planSku, planName);
if (!planCode) {
throw new SimActivationException(
`Unable to map product to Freebit plan code. SKU: ${planSku}, Name: ${planName}`,
{ orderId: orderDetails.id, planSku, planName }
);
}
await this.activateEsim({
account: phoneNumber,
eid, eid,
planCode,
activationType,
...(scheduledAt && { scheduledAt }),
...(mnp && { mnp }),
});
this.logger.log("eSIM fulfillment completed successfully", {
orderId: orderDetails.id,
account: phoneNumber,
planSku,
});
return {
activated: true,
simType: "eSIM",
phoneNumber, phoneNumber,
eid,
};
} else {
// Physical SIM activation flow:
// Non-MNP: PA02-01 + PA05-05
// MNP: PA05-19 + PA05-05
if (!assignedPhysicalSimId) {
throw new SimActivationException(
"Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)",
{ orderId: orderDetails.id }
);
}
const simData = await this.activatePhysicalSim({
orderId: orderDetails.id,
simInventoryId: assignedPhysicalSimId,
planSku, planSku,
planName, planName,
voiceMailEnabled, activationType,
callWaitingEnabled, scheduledAt,
contactIdentity, mnp,
assignmentDetails,
isMnp,
...(mnp && { mnp }),
}); });
this.logger.log("Physical SIM fulfillment completed successfully", {
orderId: orderDetails.id,
simInventoryId: assignedPhysicalSimId,
planSku,
voiceMailEnabled,
callWaitingEnabled,
phoneNumber: simData.phoneNumber,
serialNumber: simData.serialNumber,
});
return {
activated: true,
simType: "Physical SIM",
phoneNumber: simData.phoneNumber,
serialNumber: simData.serialNumber,
simInventoryId: assignedPhysicalSimId,
};
} }
return this.fulfillPhysicalSim({
orderDetails,
assignedPhysicalSimId,
planSku,
planName,
voiceMailEnabled,
callWaitingEnabled,
contactIdentity,
assignmentDetails,
isMnp,
mnp,
});
}
private async fulfillEsim(params: {
orderDetails: OrderDetails;
eid: string | undefined;
phoneNumber: string | undefined;
planSku: string;
planName: string | undefined;
activationType: "Immediate" | "Scheduled";
scheduledAt: string | undefined;
mnp: MnpConfig | undefined;
}): Promise<SimFulfillmentResult> {
const { orderDetails, eid, phoneNumber, planSku, planName, activationType, scheduledAt, mnp } =
params;
if (!eid || eid.length < 15) {
throw new SimActivationException("EID is required for eSIM and must be valid", {
orderId: orderDetails.id,
simType: "eSIM",
eidLength: eid?.length,
});
}
if (!phoneNumber) {
throw new SimActivationException("Phone number is required for eSIM activation", {
orderId: orderDetails.id,
});
}
const planCode = mapProductToFreebitPlanCode(planSku, planName);
if (!planCode) {
throw new SimActivationException(
`Unable to map product to Freebit plan code. SKU: ${planSku}, Name: ${planName}`,
{ orderId: orderDetails.id, planSku, planName }
);
}
await this.activateEsim({
account: phoneNumber,
eid,
planCode,
activationType,
...(scheduledAt && { scheduledAt }),
...(mnp && { mnp }),
});
this.logger.log("eSIM fulfillment completed successfully", {
orderId: orderDetails.id,
account: phoneNumber,
planSku,
});
return {
activated: true,
simType: "eSIM",
phoneNumber,
eid,
};
}
private async fulfillPhysicalSim(params: {
orderDetails: OrderDetails;
assignedPhysicalSimId: string | undefined;
planSku: string;
planName: string | undefined;
voiceMailEnabled: boolean;
callWaitingEnabled: boolean;
contactIdentity: ContactIdentityData | undefined;
assignmentDetails: SimAssignmentDetails | undefined;
isMnp: boolean;
mnp: MnpConfig | undefined;
}): Promise<SimFulfillmentResult> {
const {
orderDetails,
assignedPhysicalSimId,
planSku,
planName,
voiceMailEnabled,
callWaitingEnabled,
contactIdentity,
assignmentDetails,
isMnp,
mnp,
} = params;
if (!assignedPhysicalSimId) {
throw new SimActivationException(
"Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)",
{ orderId: orderDetails.id }
);
}
const simData = await this.activatePhysicalSim({
orderId: orderDetails.id,
simInventoryId: assignedPhysicalSimId,
planSku,
planName,
voiceMailEnabled,
callWaitingEnabled,
contactIdentity,
assignmentDetails,
isMnp,
...(mnp && { mnp }),
});
this.logger.log("Physical SIM fulfillment completed successfully", {
orderId: orderDetails.id,
simInventoryId: assignedPhysicalSimId,
planSku,
voiceMailEnabled,
callWaitingEnabled,
phoneNumber: simData.phoneNumber,
serialNumber: simData.serialNumber,
});
return {
activated: true,
simType: "Physical SIM",
phoneNumber: simData.phoneNumber,
serialNumber: simData.serialNumber,
simInventoryId: assignedPhysicalSimId,
};
} }
/** /**
@ -289,20 +348,7 @@ export class SimFulfillmentService {
try { try {
// Build unified MNP object with both reservation and identity data (all Level 2 per PA05-41) // Build unified MNP object with both reservation and identity data (all Level 2 per PA05-41)
const mnpPayload = const mnpPayload = isMnp ? this.buildMnpPayload(mnp) : undefined;
isMnp && mnp?.reserveNumber
? {
reserveNumber: mnp.reserveNumber,
...(mnp.reserveExpireDate && { reserveExpireDate: mnp.reserveExpireDate }),
...(mnp.lastnameKanji && { lastnameKanji: mnp.lastnameKanji }),
...(mnp.firstnameKanji && { firstnameKanji: mnp.firstnameKanji }),
...(mnp.lastnameZenKana && { lastnameZenKana: mnp.lastnameZenKana }),
...(mnp.firstnameZenKana && { firstnameZenKana: mnp.firstnameZenKana }),
// Map Salesforce gender 'F' → Freebit gender 'W' (Weiblich)
...(mnp.gender && { gender: mapGenderToFreebit(mnp.gender) }),
...(mnp.birthday && { birthday: mnp.birthday }),
}
: undefined;
const addKind = isMnp ? ("M" as const) : ("N" as const); const addKind = isMnp ? ("M" as const) : ("N" as const);
const aladinOperated = isMnp ? ("20" as const) : ("10" as const); const aladinOperated = isMnp ? ("20" as const) : ("10" as const);
@ -479,39 +525,13 @@ export class SimFulfillmentService {
} }
// Step 5: Call Freebit PA05-05 (Voice Options Registration) // Step 5: Call Freebit PA05-05 (Voice Options Registration)
// Only call if we have contact identity data await this.registerVoiceOptionsIfAvailable({
if (contactIdentity) { orderId,
this.logger.log("Calling PA05-05 Voice Options Registration", { account: accountPhoneNumber,
orderId, voiceMailEnabled,
account: accountPhoneNumber, callWaitingEnabled,
voiceMailEnabled, contactIdentity,
callWaitingEnabled, });
});
await this.freebitFacade.registerVoiceOptions({
account: accountPhoneNumber,
voiceMailEnabled,
callWaitingEnabled,
identificationData: {
lastnameKanji: contactIdentity.lastnameKanji,
firstnameKanji: contactIdentity.firstnameKanji,
lastnameKana: contactIdentity.lastnameKana,
firstnameKana: contactIdentity.firstnameKana,
gender: contactIdentity.gender,
birthday: contactIdentity.birthday,
},
});
this.logger.log("PA05-05 Voice Options Registration successful", {
orderId,
account: accountPhoneNumber,
});
} else {
this.logger.warn("Skipping PA05-05: No contact identity data provided", {
orderId,
account: accountPhoneNumber,
});
}
// Step 6: Update SIM Inventory status to "Assigned" with assignment details // Step 6: Update SIM Inventory status to "Assigned" with assignment details
await this.simInventory.markAsAssigned(simInventoryId, assignmentDetails); await this.simInventory.markAsAssigned(simInventoryId, assignmentDetails);
@ -541,6 +561,65 @@ export class SimFulfillmentService {
} }
} }
private buildMnpPayload(mnp?: MnpConfig) {
if (!mnp?.reserveNumber) return;
return {
reserveNumber: mnp.reserveNumber,
...(mnp.reserveExpireDate && { reserveExpireDate: mnp.reserveExpireDate }),
...(mnp.lastnameKanji && { lastnameKanji: mnp.lastnameKanji }),
...(mnp.firstnameKanji && { firstnameKanji: mnp.firstnameKanji }),
...(mnp.lastnameZenKana && { lastnameZenKana: mnp.lastnameZenKana }),
...(mnp.firstnameZenKana && { firstnameZenKana: mnp.firstnameZenKana }),
// Map Salesforce gender 'F' → Freebit gender 'W' (Weiblich)
...(mnp.gender && { gender: mapGenderToFreebit(mnp.gender) }),
...(mnp.birthday && { birthday: mnp.birthday }),
};
}
private async registerVoiceOptionsIfAvailable(params: {
orderId: string;
account: string;
voiceMailEnabled: boolean;
callWaitingEnabled: boolean;
contactIdentity?: ContactIdentityData | undefined;
}): Promise<void> {
const { orderId, account, voiceMailEnabled, callWaitingEnabled, contactIdentity } = params;
if (!contactIdentity) {
this.logger.warn("Skipping PA05-05: No contact identity data provided", {
orderId,
account,
});
return;
}
this.logger.log("Calling PA05-05 Voice Options Registration", {
orderId,
account,
voiceMailEnabled,
callWaitingEnabled,
});
await this.freebitFacade.registerVoiceOptions({
account,
voiceMailEnabled,
callWaitingEnabled,
identificationData: {
lastnameKanji: contactIdentity.lastnameKanji,
firstnameKanji: contactIdentity.firstnameKanji,
lastnameKana: contactIdentity.lastnameKana,
firstnameKana: contactIdentity.firstnameKana,
gender: contactIdentity.gender,
birthday: contactIdentity.birthday,
},
});
this.logger.log("PA05-05 Voice Options Registration successful", {
orderId,
account,
});
}
private readString(value: unknown): string | undefined { private readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined; return typeof value === "string" ? value : undefined;
} }
@ -549,6 +628,18 @@ export class SimFulfillmentService {
return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined; return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined;
} }
private static readonly MNP_FIELD_MAPPINGS = [
{ key: "reserveNumber", sources: ["mnpNumber", "reserveNumber"] },
{ key: "reserveExpireDate", sources: ["mnpExpiry", "reserveExpireDate"] },
{ key: "account", sources: ["mvnoAccountNumber", "account"] },
{ key: "firstnameKanji", sources: ["portingFirstName", "firstnameKanji"] },
{ key: "lastnameKanji", sources: ["portingLastName", "lastnameKanji"] },
{ key: "firstnameZenKana", sources: ["portingFirstNameKatakana", "firstnameZenKana"] },
{ key: "lastnameZenKana", sources: ["portingLastNameKatakana", "lastnameZenKana"] },
{ key: "gender", sources: ["portingGender", "gender"] },
{ key: "birthday", sources: ["portingDateOfBirth", "birthday"] },
] as const;
private extractMnpConfig(config: FulfillmentConfigurations) { private extractMnpConfig(config: FulfillmentConfigurations) {
const nested = config["mnp"]; const nested = config["mnp"];
const hasNestedMnp = nested && typeof nested === "object"; const hasNestedMnp = nested && typeof nested === "object";
@ -563,69 +654,30 @@ export class SimFulfillmentService {
return; return;
} }
const reserveNumber = this.readString(source["mnpNumber"] ?? source["reserveNumber"]); const result: Record<string, string> = {};
const reserveExpireDate = this.readString(source["mnpExpiry"] ?? source["reserveExpireDate"]); for (const { key, sources } of SimFulfillmentService.MNP_FIELD_MAPPINGS) {
const account = this.readString(source["mvnoAccountNumber"] ?? source["account"]); const value = this.readString(source[sources[0]] ?? source[sources[1]]);
const firstnameKanji = this.readString(source["portingFirstName"] ?? source["firstnameKanji"]); if (value) result[key] = value;
const lastnameKanji = this.readString(source["portingLastName"] ?? source["lastnameKanji"]); }
const firstnameZenKana = this.readString(
source["portingFirstNameKatakana"] ?? source["firstnameZenKana"]
);
const lastnameZenKana = this.readString(
source["portingLastNameKatakana"] ?? source["lastnameZenKana"]
);
const gender = this.readString(source["portingGender"] ?? source["gender"]);
const birthday = this.readString(source["portingDateOfBirth"] ?? source["birthday"]);
if ( if (Object.keys(result).length === 0) {
!reserveNumber &&
!reserveExpireDate &&
!account &&
!firstnameKanji &&
!lastnameKanji &&
!firstnameZenKana &&
!lastnameZenKana &&
!gender &&
!birthday
) {
this.logger.log("MNP extraction: no MNP fields found in config", { this.logger.log("MNP extraction: no MNP fields found in config", {
hasNestedMnp, hasNestedMnp,
isMnpFlag: isMnpFlag ?? "not-set", isMnpFlag: isMnpFlag ?? "not-set",
checkedKeys: [ checkedKeys: SimFulfillmentService.MNP_FIELD_MAPPINGS.flatMap(m => m.sources),
"mnpNumber",
"reserveNumber",
"mnpExpiry",
"portingFirstName",
"portingLastName",
"portingGender",
"portingDateOfBirth",
],
}); });
return; return;
} }
// Build object with only defined properties (for exactOptionalPropertyTypes)
const result = {
...(reserveNumber && { reserveNumber }),
...(reserveExpireDate && { reserveExpireDate }),
...(account && { account }),
...(firstnameKanji && { firstnameKanji }),
...(lastnameKanji && { lastnameKanji }),
...(firstnameZenKana && { firstnameZenKana }),
...(lastnameZenKana && { lastnameZenKana }),
...(gender && { gender }),
...(birthday && { birthday }),
};
this.logger.log("MNP config extracted", { this.logger.log("MNP config extracted", {
hasReserveNumber: !!reserveNumber, hasReserveNumber: !!result["reserveNumber"],
reserveNumberLength: reserveNumber?.length, reserveNumberLength: result["reserveNumber"]?.length,
hasReserveExpireDate: !!reserveExpireDate, hasReserveExpireDate: !!result["reserveExpireDate"],
hasAccount: !!account, hasAccount: !!result["account"],
hasIdentity: !!(firstnameKanji && lastnameKanji), hasIdentity: !!(result["firstnameKanji"] && result["lastnameKanji"]),
hasKana: !!(firstnameZenKana && lastnameZenKana), hasKana: !!(result["firstnameZenKana"] && result["lastnameZenKana"]),
gender: gender ?? "not-set", gender: result["gender"] ?? "not-set",
hasBirthday: !!birthday, hasBirthday: !!result["birthday"],
totalFieldsExtracted: Object.keys(result).length, totalFieldsExtracted: Object.keys(result).length,
}); });

View File

@ -3,6 +3,7 @@ import { Logger } from "nestjs-pino";
import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js"; import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js";
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js"; import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { normalizeToArray } from "@bff/core/utils/array.util.js";
import { import {
ValidationErrorCode, ValidationErrorCode,
createValidationError, createValidationError,
@ -75,11 +76,7 @@ export class InternetOrderValidator {
try { try {
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId }); const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
const productContainer = products.products?.product; const productContainer = products.products?.product;
const existing = Array.isArray(productContainer) const existing = normalizeToArray(productContainer);
? productContainer
: productContainer
? [productContainer]
: [];
// Check for active Internet products // Check for active Internet products
const activeInternetProducts = existing.filter((product: WhmcsProduct) => { const activeInternetProducts = existing.filter((product: WhmcsProduct) => {

View File

@ -52,11 +52,14 @@ export class WorkflowCaseManager {
? this.buildLightningUrl("Opportunity", opportunityId) ? this.buildLightningUrl("Opportunity", opportunityId)
: null; : null;
const opportunityStatus = opportunityId let opportunityStatus: string;
? opportunityCreated if (!opportunityId) {
? "Created new opportunity for this order" opportunityStatus = "No opportunity linked";
: "Linked to existing opportunity" } else if (opportunityCreated) {
: "No opportunity linked"; opportunityStatus = "Created new opportunity for this order";
} else {
opportunityStatus = "Linked to existing opportunity";
}
const description = this.buildDescription([ const description = this.buildDescription([
"Order placed via Customer Portal.", "Order placed via Customer Portal.",

View File

@ -20,10 +20,7 @@ import {
type SimInternationalCallHistoryResponse, type SimInternationalCallHistoryResponse,
type SimSmsHistoryResponse, type SimSmsHistoryResponse,
} from "@customer-portal/domain/sim"; } from "@customer-portal/domain/sim";
import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js";
// Cache-Control header constants
const CACHE_CONTROL_PUBLIC_1H = "public, max-age=3600";
const CACHE_CONTROL_PRIVATE_5M = "private, max-age=300";
// DTOs // DTOs
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {} class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
@ -56,7 +53,7 @@ export class CallHistoryController {
*/ */
@Public() @Public()
@Get("sim/call-history/available-months") @Get("sim/call-history/available-months")
@Header("Cache-Control", CACHE_CONTROL_PUBLIC_1H) @Header("Cache-Control", CACHE_CONTROL.PUBLIC_1H)
@ZodResponse({ @ZodResponse({
description: "Get available call/SMS history months", description: "Get available call/SMS history months",
type: SimHistoryAvailableMonthsResponseDto, type: SimHistoryAvailableMonthsResponseDto,
@ -97,7 +94,7 @@ export class CallHistoryController {
* Get domestic call history * Get domestic call history
*/ */
@Get(":id/sim/call-history/domestic") @Get(":id/sim/call-history/domestic")
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
@ZodResponse({ @ZodResponse({
description: "Get domestic call history", description: "Get domestic call history",
type: SimDomesticCallHistoryResponseDto, type: SimDomesticCallHistoryResponseDto,
@ -121,7 +118,7 @@ export class CallHistoryController {
* Get international call history * Get international call history
*/ */
@Get(":id/sim/call-history/international") @Get(":id/sim/call-history/international")
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
@ZodResponse({ @ZodResponse({
description: "Get international call history", description: "Get international call history",
type: SimInternationalCallHistoryResponseDto, type: SimInternationalCallHistoryResponseDto,
@ -145,7 +142,7 @@ export class CallHistoryController {
* Get SMS history * Get SMS history
*/ */
@Get(":id/sim/sms-history") @Get(":id/sim/sms-history")
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
@ZodResponse({ description: "Get SMS history", type: SimSmsHistoryResponseDto }) @ZodResponse({ description: "Get SMS history", type: SimSmsHistoryResponseDto })
async getSmsHistory( async getSmsHistory(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,

View File

@ -18,6 +18,7 @@ import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-clien
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js"; import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
import { EmailService } from "@bff/infra/email/email.service.js"; import { EmailService } from "@bff/infra/email/email.service.js";
import { normalizeToArray } from "@bff/core/utils/array.util.js";
import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import { SubscriptionValidationCoordinator } from "../../shared/index.js"; import { SubscriptionValidationCoordinator } from "../../shared/index.js";
import { import {
@ -75,11 +76,7 @@ export class InternetCancellationService {
clientid: whmcsClientId, clientid: whmcsClientId,
}); });
const productContainer = productsResponse.products?.product; const productContainer = productsResponse.products?.product;
const products = Array.isArray(productContainer) const products = normalizeToArray(productContainer);
? productContainer
: productContainer
? [productContainer]
: [];
const subscription = products.find( const subscription = products.find(
(p: { id?: number | string }) => Number(p.id) === subscriptionId (p: { id?: number | string }) => Number(p.id) === subscriptionId

View File

@ -9,6 +9,11 @@ import {
isSimSubscription, isSimSubscription,
} from "@customer-portal/domain/sim"; } from "@customer-portal/domain/sim";
function getAccountSource(account: string | null | undefined, domain: string | undefined): string {
if (!account) return "NOT FOUND - check fields below";
return domain ? "domain field" : "custom field or order number";
}
@Injectable() @Injectable()
export class SimValidationService { export class SimValidationService {
constructor( constructor(
@ -103,11 +108,7 @@ export class SimValidationService {
status: subscription.status, status: subscription.status,
// Account extraction result // Account extraction result
extractedAccount, extractedAccount,
accountSource: extractedAccount accountSource: getAccountSource(extractedAccount, subscription.domain),
? subscription.domain
? "domain field"
: "custom field or order number"
: "NOT FOUND - check fields below",
// All custom fields for debugging // All custom fields for debugging
customFieldKeys: Object.keys(subscription.customFields || {}), customFieldKeys: Object.keys(subscription.customFields || {}),
customFields: subscription.customFields, customFields: subscription.customFields,

View File

@ -47,6 +47,7 @@ import {
type SimAvailablePlan, type SimAvailablePlan,
type SimCancellationPreview, type SimCancellationPreview,
} from "@customer-portal/domain/sim"; } from "@customer-portal/domain/sim";
import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js";
// DTOs // DTOs
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {} class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
@ -89,7 +90,7 @@ export class SimController {
// ==================== Static SIM Routes (must be before :id routes) ==================== // ==================== Static SIM Routes (must be before :id routes) ====================
@Get("sim/top-up/pricing") @Get("sim/top-up/pricing")
@Header("Cache-Control", "public, max-age=3600") @Header("Cache-Control", CACHE_CONTROL.PUBLIC_1H)
@ZodResponse({ description: "Get SIM top-up pricing", type: SimTopUpPricingResponseDto }) @ZodResponse({ description: "Get SIM top-up pricing", type: SimTopUpPricingResponseDto })
async getSimTopUpPricing() { async getSimTopUpPricing() {
const pricing = await this.simTopUpPricingService.getTopUpPricing(); const pricing = await this.simTopUpPricingService.getTopUpPricing();
@ -97,7 +98,7 @@ export class SimController {
} }
@Get("sim/top-up/pricing/preview") @Get("sim/top-up/pricing/preview")
@Header("Cache-Control", "public, max-age=3600") @Header("Cache-Control", CACHE_CONTROL.PUBLIC_1H)
@ZodResponse({ @ZodResponse({
description: "Preview SIM top-up pricing", description: "Preview SIM top-up pricing",
type: SimTopUpPricingPreviewResponseDto, type: SimTopUpPricingPreviewResponseDto,
@ -220,7 +221,7 @@ export class SimController {
// ==================== Enhanced SIM Management Endpoints ==================== // ==================== Enhanced SIM Management Endpoints ====================
@Get(":id/sim/available-plans") @Get(":id/sim/available-plans")
@Header("Cache-Control", "private, max-age=300") @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
@ZodResponse({ description: "Get available SIM plans", type: SimAvailablePlansResponseDto }) @ZodResponse({ description: "Get available SIM plans", type: SimAvailablePlansResponseDto })
async getAvailablePlans( async getAvailablePlans(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@ -245,7 +246,7 @@ export class SimController {
} }
@Get(":id/sim/cancellation-preview") @Get(":id/sim/cancellation-preview")
@Header("Cache-Control", "private, max-age=60") @Header("Cache-Control", CACHE_CONTROL.PRIVATE_1M)
@ZodResponse({ @ZodResponse({
description: "Get SIM cancellation preview", description: "Get SIM cancellation preview",
type: SimCancellationPreviewResponseDto, type: SimCancellationPreviewResponseDto,

View File

@ -18,10 +18,7 @@ import { Validation } from "@customer-portal/domain/toolkit";
import { createZodDto, ZodResponse } from "nestjs-zod"; import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { invoiceListSchema } from "@customer-portal/domain/billing"; import { invoiceListSchema } from "@customer-portal/domain/billing";
import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js";
// Cache-Control header constants
const CACHE_CONTROL_PRIVATE_5M = "private, max-age=300";
const CACHE_CONTROL_PRIVATE_1M = "private, max-age=60";
const subscriptionInvoiceQuerySchema = Validation.createPaginationSchema({ const subscriptionInvoiceQuerySchema = Validation.createPaginationSchema({
defaultLimit: 10, defaultLimit: 10,
@ -54,7 +51,7 @@ export class SubscriptionsController {
constructor(private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator) {} constructor(private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator) {}
@Get() @Get()
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
@ZodResponse({ description: "List subscriptions", type: SubscriptionListDto }) @ZodResponse({ description: "List subscriptions", type: SubscriptionListDto })
async getSubscriptions( async getSubscriptions(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@ -68,21 +65,21 @@ export class SubscriptionsController {
} }
@Get("active") @Get("active")
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
@ZodResponse({ description: "List active subscriptions", type: ActiveSubscriptionsDto }) @ZodResponse({ description: "List active subscriptions", type: ActiveSubscriptionsDto })
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> { async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
return this.subscriptionsOrchestrator.getActiveSubscriptions(req.user.id); return this.subscriptionsOrchestrator.getActiveSubscriptions(req.user.id);
} }
@Get("stats") @Get("stats")
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
@ZodResponse({ description: "Get subscription stats", type: SubscriptionStatsDto }) @ZodResponse({ description: "Get subscription stats", type: SubscriptionStatsDto })
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> { async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> {
return this.subscriptionsOrchestrator.getSubscriptionStats(req.user.id); return this.subscriptionsOrchestrator.getSubscriptionStats(req.user.id);
} }
@Get(":id") @Get(":id")
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M) @Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
@ZodResponse({ description: "Get subscription", type: SubscriptionDto }) @ZodResponse({ description: "Get subscription", type: SubscriptionDto })
async getSubscriptionById( async getSubscriptionById(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@ -92,7 +89,7 @@ export class SubscriptionsController {
} }
@Get(":id/invoices") @Get(":id/invoices")
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_1M) @Header("Cache-Control", CACHE_CONTROL.PRIVATE_1M)
@ZodResponse({ description: "Get subscription invoices", type: InvoiceListDto }) @ZodResponse({ description: "Get subscription invoices", type: InvoiceListDto })
async getSubscriptionInvoices( async getSubscriptionInvoices(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,

View File

@ -24,6 +24,14 @@ import { basename, extname } from "node:path";
// Default fallback filename for residence card submissions // Default fallback filename for residence card submissions
const DEFAULT_FILENAME = "residence-card"; const DEFAULT_FILENAME = "residence-card";
function extractNonEmptyString(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
return null;
}
@Injectable() @Injectable()
export class ResidenceCardService { export class ResidenceCardService {
// eslint-disable-next-line max-params -- NestJS DI requires injecting multiple services // eslint-disable-next-line max-params -- NestJS DI requires injecting multiple services
@ -91,11 +99,7 @@ export class ResidenceCardService {
const reviewedAt = normalizeSalesforceDateTimeToIsoUtc(verifiedAtRaw); const reviewedAt = normalizeSalesforceDateTimeToIsoUtc(verifiedAtRaw);
const reviewerNotes = const reviewerNotes =
typeof rejectionRaw === "string" && rejectionRaw.trim().length > 0 extractNonEmptyString(rejectionRaw) ?? extractNonEmptyString(noteRaw) ?? null;
? rejectionRaw.trim()
: typeof noteRaw === "string" && noteRaw.trim().length > 0
? noteRaw.trim()
: null;
return residenceCardVerificationSchema.parse({ return residenceCardVerificationSchema.parse({
status, status,

View File

@ -561,6 +561,12 @@ export function getCustomerPhaseFromStage(
} }
} }
function getStepStatus(index: number, currentStep: number): "completed" | "current" | "upcoming" {
if (index < currentStep) return "completed";
if (index === currentStep) return "current";
return "upcoming";
}
/** /**
* Get order tracking progress steps * Get order tracking progress steps
* Used for customers who have placed an order but WHMCS is not yet active * Used for customers who have placed an order but WHMCS is not yet active
@ -585,7 +591,7 @@ export function getOrderTrackingSteps(
return stages.map((s, index) => ({ return stages.map((s, index) => ({
label: s.label, label: s.label,
status: index < currentStep ? "completed" : index === currentStep ? "current" : "upcoming", status: getStepStatus(index, currentStep),
})); }));
} }

View File

@ -28,6 +28,11 @@ import {
normalizeCycle, normalizeCycle,
} from "../../../common/providers/whmcs-utils/index.js"; } from "../../../common/providers/whmcs-utils/index.js";
function normalizeToArray<T>(value: T | T[] | undefined | null): T[] {
if (Array.isArray(value)) return value;
return value ? [value] : [];
}
export interface TransformSubscriptionOptions { export interface TransformSubscriptionOptions {
defaultCurrencyCode?: string; defaultCurrencyCode?: string;
defaultCurrencySymbol?: string; defaultCurrencySymbol?: string;
@ -193,11 +198,7 @@ export function transformWhmcsSubscriptionListResponse(
} }
const productContainer = parsed.products?.product; const productContainer = parsed.products?.product;
const products = Array.isArray(productContainer) const products = normalizeToArray(productContainer);
? productContainer
: productContainer
? [productContainer]
: [];
const subscriptions: Subscription[] = []; const subscriptions: Subscription[] = [];
for (const product of products) { for (const product of products) {