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:
parent
953878e6c6
commit
26776373f7
13
apps/bff/src/core/constants/http.constants.ts
Normal file
13
apps/bff/src/core/constants/http.constants.ts
Normal 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;
|
||||
1
apps/bff/src/core/constants/index.ts
Normal file
1
apps/bff/src/core/constants/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./http.constants.js";
|
||||
@ -86,16 +86,16 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
|
||||
const userMessage = this.getUserMessage(errorCode, originalMessage, fieldErrors);
|
||||
|
||||
// Log the error
|
||||
this.logError(errorCode, originalMessage, status, errorContext, exception);
|
||||
this.logError({ errorCode, originalMessage, status, context: errorContext, exception });
|
||||
|
||||
// Build and send response
|
||||
const errorResponse = this.buildErrorResponse(
|
||||
const errorResponse = this.buildErrorResponse({
|
||||
errorCode,
|
||||
userMessage,
|
||||
message: userMessage,
|
||||
status,
|
||||
errorContext,
|
||||
fieldErrors
|
||||
);
|
||||
context: errorContext,
|
||||
fieldErrors,
|
||||
});
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
|
||||
@ -298,34 +298,36 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
|
||||
private buildErrorContext(
|
||||
request: Request & { user?: { id?: string }; requestId?: string }
|
||||
): ErrorContext {
|
||||
const userAgentHeader = request.headers["user-agent"];
|
||||
const userAgent =
|
||||
typeof userAgentHeader === "string"
|
||||
? userAgentHeader
|
||||
: Array.isArray(userAgentHeader)
|
||||
? userAgentHeader[0]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
requestId: request.requestId ?? this.generateRequestId(),
|
||||
userId: request.user?.id,
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
userAgent,
|
||||
userAgent: this.extractUserAgent(request.headers["user-agent"]),
|
||||
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
|
||||
*/
|
||||
private logError(
|
||||
errorCode: ErrorCodeType,
|
||||
originalMessage: string,
|
||||
status: number,
|
||||
context: ErrorContext,
|
||||
exception: unknown
|
||||
): void {
|
||||
private logError(params: {
|
||||
errorCode: ErrorCodeType;
|
||||
originalMessage: string;
|
||||
status: number;
|
||||
context: ErrorContext;
|
||||
exception: unknown;
|
||||
}): void {
|
||||
const { errorCode, originalMessage, status, context, exception } = params;
|
||||
const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN];
|
||||
|
||||
const logData = {
|
||||
@ -370,13 +372,14 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
|
||||
/**
|
||||
* Build standard error response
|
||||
*/
|
||||
private buildErrorResponse(
|
||||
errorCode: ErrorCodeType,
|
||||
message: string,
|
||||
status: number,
|
||||
context: ErrorContext,
|
||||
fieldErrors?: Record<string, string>
|
||||
): ApiError {
|
||||
private buildErrorResponse(params: {
|
||||
errorCode: ErrorCodeType;
|
||||
message: string;
|
||||
status: number;
|
||||
context: ErrorContext;
|
||||
fieldErrors?: Record<string, string> | undefined;
|
||||
}): ApiError {
|
||||
const { errorCode, message, status, context, fieldErrors } = params;
|
||||
const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN];
|
||||
|
||||
return {
|
||||
|
||||
@ -12,6 +12,8 @@ export type AuthenticatedRequest = Request & {
|
||||
sessionID?: string;
|
||||
};
|
||||
|
||||
const USER_AGENT_HEADER = "user-agent";
|
||||
|
||||
@Controller("security/csrf")
|
||||
export class CsrfController {
|
||||
constructor(
|
||||
@ -23,69 +25,13 @@ export class CsrfController {
|
||||
@Public()
|
||||
@Get("token")
|
||||
getCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
|
||||
const sessionId = this.extractSessionId(req) || undefined;
|
||||
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(),
|
||||
});
|
||||
return this.generateAndSetToken(req, res, "CSRF token requested");
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("refresh")
|
||||
refreshCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
|
||||
const sessionId = this.extractSessionId(req) || undefined;
|
||||
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(),
|
||||
});
|
||||
return this.generateAndSetToken(req, res, "CSRF token refreshed");
|
||||
}
|
||||
|
||||
@UseGuards(AdminGuard)
|
||||
@ -95,7 +41,7 @@ export class CsrfController {
|
||||
|
||||
this.logger.debug("CSRF stats requested", {
|
||||
userId,
|
||||
userAgent: req.get("user-agent"),
|
||||
userAgent: req.get(USER_AGENT_HEADER),
|
||||
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 {
|
||||
const cookies = req.cookies as Record<string, string | undefined> | undefined;
|
||||
const sessionCookie = cookies?.["session-id"] ?? cookies?.["connect.sid"];
|
||||
|
||||
@ -23,6 +23,8 @@ type CsrfRequest = Omit<BaseExpressRequest, "cookies"> & {
|
||||
cookies: CookieJar;
|
||||
};
|
||||
|
||||
const USER_AGENT_HEADER = "user-agent";
|
||||
|
||||
/**
|
||||
* CSRF Protection Middleware
|
||||
* Implements a double-submit cookie pattern:
|
||||
@ -101,7 +103,7 @@ export class CsrfMiddleware implements NestMiddleware {
|
||||
this.logger.warn("CSRF validation failed - missing token", {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
userAgent: req.get("user-agent"),
|
||||
userAgent: req.get(USER_AGENT_HEADER),
|
||||
ip: req.ip,
|
||||
});
|
||||
throw new ForbiddenException("CSRF token required");
|
||||
@ -111,7 +113,7 @@ export class CsrfMiddleware implements NestMiddleware {
|
||||
this.logger.warn("CSRF validation failed - missing secret cookie", {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
userAgent: req.get("user-agent"),
|
||||
userAgent: req.get(USER_AGENT_HEADER),
|
||||
ip: req.ip,
|
||||
});
|
||||
throw new ForbiddenException("CSRF secret required");
|
||||
@ -129,7 +131,7 @@ export class CsrfMiddleware implements NestMiddleware {
|
||||
reason: validationResult.reason,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
userAgent: req.get("user-agent"),
|
||||
userAgent: req.get(USER_AGENT_HEADER),
|
||||
ip: req.ip,
|
||||
userId,
|
||||
sessionId,
|
||||
|
||||
@ -65,3 +65,12 @@ export function uniqueBy<T, K>(array: T[], keyFn: (item: T) => K): T[] {
|
||||
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] : [];
|
||||
}
|
||||
|
||||
@ -5,11 +5,16 @@
|
||||
*/
|
||||
|
||||
// Array utilities
|
||||
export { chunkArray, groupBy, uniqueBy } from "./array.util.js";
|
||||
export { chunkArray, groupBy, uniqueBy, normalizeToArray } from "./array.util.js";
|
||||
|
||||
// Error utilities
|
||||
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
|
||||
export {
|
||||
|
||||
@ -39,7 +39,7 @@ export interface SafeOperationOptions<T> {
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
/** 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) */
|
||||
fallbackMessage?: string;
|
||||
@ -89,7 +89,15 @@ export async function safeOperation<T>(
|
||||
executor: () => Promise<T>,
|
||||
options: SafeOperationOptions<T>
|
||||
): Promise<T> {
|
||||
const { criticality, fallback, context, logger, metadata = {}, rethrow, fallbackMessage } = options;
|
||||
const {
|
||||
criticality,
|
||||
fallback,
|
||||
context,
|
||||
logger,
|
||||
metadata = {},
|
||||
rethrow,
|
||||
fallbackMessage,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
return await executor();
|
||||
|
||||
@ -34,6 +34,7 @@ export interface ParsedSendGridError {
|
||||
|
||||
/** HTTP status codes that indicate transient failures worth retrying */
|
||||
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
|
||||
const UNKNOWN_ERROR = "Unknown error";
|
||||
|
||||
@Injectable()
|
||||
export class SendGridEmailProvider implements OnModuleInit {
|
||||
@ -199,20 +200,20 @@ export class SendGridEmailProvider implements OnModuleInit {
|
||||
|
||||
private extractErrorsFromBody(body: unknown): SendGridErrorDetail[] {
|
||||
if (!body || typeof body !== "object") {
|
||||
return [{ message: "Unknown error" }];
|
||||
return [{ message: UNKNOWN_ERROR }];
|
||||
}
|
||||
|
||||
// SendGrid returns { errors: [...] } in the body
|
||||
const bodyObj = body as { errors?: Array<{ message?: string; field?: string; help?: string }> };
|
||||
if (Array.isArray(bodyObj.errors)) {
|
||||
return bodyObj.errors.map(e => ({
|
||||
message: e.message || "Unknown error",
|
||||
message: e.message || UNKNOWN_ERROR,
|
||||
field: e.field,
|
||||
help: e.help,
|
||||
}));
|
||||
}
|
||||
|
||||
return [{ message: "Unknown error" }];
|
||||
return [{ message: UNKNOWN_ERROR }];
|
||||
}
|
||||
|
||||
private isSendGridError(error: unknown): error is ResponseError {
|
||||
|
||||
@ -14,6 +14,8 @@ interface ParsedSendGridError {
|
||||
isRetryable: boolean;
|
||||
}
|
||||
|
||||
const SERVICE_NAME = "email-processor";
|
||||
|
||||
/** TTL for idempotency keys (24 hours) - prevents duplicate sends on delayed retries */
|
||||
const IDEMPOTENCY_TTL_SECONDS = 86400;
|
||||
const IDEMPOTENCY_PREFIX = "email:sent:";
|
||||
@ -31,7 +33,7 @@ export class EmailProcessor extends WorkerHost {
|
||||
|
||||
async process(job: Job<EmailJobData>): Promise<void> {
|
||||
const jobContext = {
|
||||
service: "email-processor",
|
||||
service: SERVICE_NAME,
|
||||
jobId: job.id,
|
||||
attempt: job.attemptsMade + 1,
|
||||
maxAttempts: job.opts.attempts || 3,
|
||||
@ -88,7 +90,7 @@ export class EmailProcessor extends WorkerHost {
|
||||
@OnWorkerEvent("completed")
|
||||
onCompleted(job: Job<EmailJobData>): void {
|
||||
this.logger.debug("Email job marked complete", {
|
||||
service: "email-processor",
|
||||
service: SERVICE_NAME,
|
||||
jobId: job.id,
|
||||
category: job.data.category,
|
||||
});
|
||||
@ -98,7 +100,7 @@ export class EmailProcessor extends WorkerHost {
|
||||
onFailed(job: Job<EmailJobData> | undefined, error: Error): void {
|
||||
if (!job) {
|
||||
this.logger.error("Email job failed (job undefined)", {
|
||||
service: "email-processor",
|
||||
service: SERVICE_NAME,
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
@ -109,7 +111,7 @@ export class EmailProcessor extends WorkerHost {
|
||||
|
||||
if (isLastAttempt) {
|
||||
this.logger.error("Email job permanently failed - all retries exhausted", {
|
||||
service: "email-processor",
|
||||
service: SERVICE_NAME,
|
||||
jobId: job.id,
|
||||
category: job.data.category,
|
||||
subject: job.data.subject,
|
||||
@ -119,7 +121,7 @@ export class EmailProcessor extends WorkerHost {
|
||||
});
|
||||
} else {
|
||||
this.logger.warn("Email job failed - will retry", {
|
||||
service: "email-processor",
|
||||
service: SERVICE_NAME,
|
||||
jobId: job.id,
|
||||
category: job.data.category,
|
||||
attempt: job.attemptsMade,
|
||||
@ -133,7 +135,7 @@ export class EmailProcessor extends WorkerHost {
|
||||
@OnWorkerEvent("error")
|
||||
onError(error: Error): void {
|
||||
this.logger.error("Email processor worker error", {
|
||||
service: "email-processor",
|
||||
service: SERVICE_NAME,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
|
||||
@ -14,6 +14,8 @@ export interface EmailJobResult {
|
||||
queued: boolean;
|
||||
}
|
||||
|
||||
const SERVICE_NAME = "email-queue";
|
||||
|
||||
/** Queue configuration constants */
|
||||
const QUEUE_CONFIG = {
|
||||
/** Keep last N completed jobs for debugging */
|
||||
@ -35,7 +37,7 @@ export class EmailQueueService {
|
||||
|
||||
async enqueueEmail(data: EmailJobData): Promise<EmailJobResult> {
|
||||
const jobContext = {
|
||||
service: "email-queue",
|
||||
service: SERVICE_NAME,
|
||||
queue: QUEUE_NAMES.EMAIL,
|
||||
category: data.category || "transactional",
|
||||
recipientCount: Array.isArray(data.to) ? data.to.length : 1,
|
||||
@ -108,7 +110,7 @@ export class EmailQueueService {
|
||||
const job = await this.queue.getJob(jobId);
|
||||
if (!job) {
|
||||
this.logger.warn("Job not found for retry", {
|
||||
service: "email-queue",
|
||||
service: SERVICE_NAME,
|
||||
jobId,
|
||||
});
|
||||
return;
|
||||
@ -116,7 +118,7 @@ export class EmailQueueService {
|
||||
|
||||
await job.retry();
|
||||
this.logger.log("Job retry triggered", {
|
||||
service: "email-queue",
|
||||
service: SERVICE_NAME,
|
||||
jobId,
|
||||
category: job.data.category,
|
||||
});
|
||||
|
||||
@ -255,19 +255,26 @@ export class FreebitClientService {
|
||||
}
|
||||
|
||||
const timestamp = this.testTracker.getCurrentTimestamp();
|
||||
const resultCode = response?.resultCode
|
||||
? String(response.resultCode)
|
||||
: error instanceof FreebitError
|
||||
? String(error.resultCode || "ERROR")
|
||||
: "ERROR";
|
||||
|
||||
const statusMessage =
|
||||
response?.status?.message ||
|
||||
(error instanceof FreebitError
|
||||
? error.message
|
||||
: error
|
||||
? extractErrorMessage(error)
|
||||
: "Success");
|
||||
let resultCode: string;
|
||||
if (response?.resultCode) {
|
||||
resultCode = String(response.resultCode);
|
||||
} else if (error instanceof FreebitError) {
|
||||
resultCode = String(error.resultCode || "ERROR");
|
||||
} else {
|
||||
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({
|
||||
timestamp,
|
||||
|
||||
@ -11,6 +11,8 @@ import type {
|
||||
} from "../interfaces/freebit.types.js";
|
||||
import type { VoiceOptionsService } from "@bff/modules/voice-options/services/voice-options.service.js";
|
||||
|
||||
const NOT_IN_API_RESPONSE = "(not in API response)";
|
||||
|
||||
@Injectable()
|
||||
export class FreebitMapperService {
|
||||
constructor(
|
||||
@ -143,9 +145,9 @@ export class FreebitMapperService {
|
||||
"[FreebitMapper] No stored options found, using API values (default: disabled)",
|
||||
{
|
||||
account: account.account,
|
||||
rawVoiceMail: account.voicemail ?? account.voiceMail ?? "(not in API response)",
|
||||
rawCallWaiting: account.callwaiting ?? account.callWaiting ?? "(not in API response)",
|
||||
rawWorldWing: account.worldwing ?? account.worldWing ?? "(not in API response)",
|
||||
rawVoiceMail: account.voicemail ?? account.voiceMail ?? NOT_IN_API_RESPONSE,
|
||||
rawCallWaiting: account.callwaiting ?? account.callWaiting ?? NOT_IN_API_RESPONSE,
|
||||
rawWorldWing: account.worldwing ?? account.worldWing ?? NOT_IN_API_RESPONSE,
|
||||
parsedVoiceMailEnabled: voiceMailEnabled,
|
||||
parsedCallWaitingEnabled: callWaitingEnabled,
|
||||
parsedInternationalRoamingEnabled: internationalRoamingEnabled,
|
||||
|
||||
@ -362,11 +362,14 @@ export class AccountCreationWorkflowService {
|
||||
});
|
||||
|
||||
// Step 6: Generate Auth Result
|
||||
const auditSource = withEligibility
|
||||
? "signup_with_eligibility"
|
||||
: isNewCustomer
|
||||
? "get_started_new_customer"
|
||||
: "get_started_complete_account";
|
||||
let auditSource: string;
|
||||
if (withEligibility) {
|
||||
auditSource = "signup_with_eligibility";
|
||||
} else if (isNewCustomer) {
|
||||
auditSource = "get_started_new_customer";
|
||||
} else {
|
||||
auditSource = "get_started_complete_account";
|
||||
}
|
||||
|
||||
const authResult = await this.authResultStep.execute({
|
||||
userId: portalUserResult.userId,
|
||||
|
||||
@ -43,8 +43,11 @@ export class CheckoutService {
|
||||
*/
|
||||
private summarizeSelectionsForLog(selections: OrderSelections): Record<string, unknown> {
|
||||
const addons = this.collectAddonRefs(selections);
|
||||
const normalizeBool = (value?: string) =>
|
||||
value === "true" ? true : value === "false" ? false : undefined;
|
||||
const normalizeBool = (value?: string): boolean | undefined => {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
planSku: selections.planSku,
|
||||
|
||||
@ -19,6 +19,9 @@ import type {
|
||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||
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 OrderSummaryResponse = OrderSummary;
|
||||
|
||||
@ -214,18 +217,18 @@ export class OrderOrchestrator {
|
||||
async getOrderForUser(orderId: string, userId: string): Promise<OrderDetailsResponse> {
|
||||
const userMapping = await this.orderValidator.validateUserMapping(userId);
|
||||
const sfAccountId = userMapping.sfAccountId
|
||||
? assertSalesforceId(userMapping.sfAccountId, "sfAccountId")
|
||||
? assertSalesforceId(userMapping.sfAccountId, SF_ACCOUNT_ID_FIELD)
|
||||
: null;
|
||||
|
||||
if (!sfAccountId) {
|
||||
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 order = await this.getOrder(safeOrderId);
|
||||
if (!order) {
|
||||
throw new NotFoundException("Order not found");
|
||||
throw new NotFoundException(ORDER_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!order.accountId || order.accountId !== sfAccountId) {
|
||||
@ -238,7 +241,7 @@ export class OrderOrchestrator {
|
||||
},
|
||||
"Order access denied due to account mismatch"
|
||||
);
|
||||
throw new NotFoundException("Order not found");
|
||||
throw new NotFoundException(ORDER_NOT_FOUND);
|
||||
}
|
||||
|
||||
return order;
|
||||
@ -253,7 +256,7 @@ export class OrderOrchestrator {
|
||||
// Get user mapping
|
||||
const userMapping = await this.orderValidator.validateUserMapping(userId);
|
||||
const sfAccountId = userMapping.sfAccountId
|
||||
? assertSalesforceId(userMapping.sfAccountId, "sfAccountId")
|
||||
? assertSalesforceId(userMapping.sfAccountId, SF_ACCOUNT_ID_FIELD)
|
||||
: undefined;
|
||||
|
||||
if (!sfAccountId) {
|
||||
|
||||
@ -4,6 +4,7 @@ import { ZodError } from "zod";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { normalizeToArray } from "@bff/core/utils/array.util.js";
|
||||
import {
|
||||
orderWithSkuValidationSchema,
|
||||
type CreateOrderRequest,
|
||||
@ -82,11 +83,7 @@ export class OrderValidator {
|
||||
try {
|
||||
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
|
||||
const productContainer = products.products?.product;
|
||||
const existing = Array.isArray(productContainer)
|
||||
? productContainer
|
||||
: productContainer
|
||||
? [productContainer]
|
||||
: [];
|
||||
const existing = normalizeToArray(productContainer);
|
||||
|
||||
// Check for active Internet products
|
||||
const activeInternetProducts = existing.filter((product: WhmcsProduct) => {
|
||||
|
||||
@ -141,15 +141,15 @@ export class SimFulfillmentService {
|
||||
const scheduledAt = this.readString(configurations["scheduledAt"]);
|
||||
const phoneNumber = this.readString(configurations["mnpPhone"]);
|
||||
const mnp = this.extractMnpConfig(configurations);
|
||||
const isMnp = !!mnp?.reserveNumber;
|
||||
const isMnp = !!mnp?.["reserveNumber"];
|
||||
|
||||
this.logger.log("MNP detection result", {
|
||||
orderId: orderDetails.id,
|
||||
isMnp,
|
||||
simType,
|
||||
mnpReserveNumber: mnp?.reserveNumber,
|
||||
mnpHasIdentity: !!(mnp?.lastnameKanji || mnp?.firstnameKanji),
|
||||
mnpGender: mnp?.gender,
|
||||
mnpReserveNumber: mnp?.["reserveNumber"],
|
||||
mnpHasIdentity: !!(mnp?.["lastnameKanji"] || mnp?.["firstnameKanji"]),
|
||||
mnpGender: mnp?.["gender"],
|
||||
});
|
||||
|
||||
const simPlanItem = orderDetails.items.find(
|
||||
@ -173,93 +173,152 @@ export class SimFulfillmentService {
|
||||
}
|
||||
|
||||
if (simType === "eSIM") {
|
||||
// eSIM activation flow
|
||||
if (!eid || eid.length < 15) {
|
||||
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,
|
||||
return this.fulfillEsim({
|
||||
orderDetails,
|
||||
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,
|
||||
};
|
||||
} 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,
|
||||
planName,
|
||||
voiceMailEnabled,
|
||||
callWaitingEnabled,
|
||||
contactIdentity,
|
||||
assignmentDetails,
|
||||
isMnp,
|
||||
...(mnp && { mnp }),
|
||||
activationType,
|
||||
scheduledAt,
|
||||
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 {
|
||||
// Build unified MNP object with both reservation and identity data (all Level 2 per PA05-41)
|
||||
const mnpPayload =
|
||||
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 mnpPayload = isMnp ? this.buildMnpPayload(mnp) : undefined;
|
||||
|
||||
const addKind = isMnp ? ("M" as const) : ("N" 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)
|
||||
// Only call if we have contact identity data
|
||||
if (contactIdentity) {
|
||||
this.logger.log("Calling PA05-05 Voice Options Registration", {
|
||||
orderId,
|
||||
account: accountPhoneNumber,
|
||||
voiceMailEnabled,
|
||||
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,
|
||||
});
|
||||
}
|
||||
await this.registerVoiceOptionsIfAvailable({
|
||||
orderId,
|
||||
account: accountPhoneNumber,
|
||||
voiceMailEnabled,
|
||||
callWaitingEnabled,
|
||||
contactIdentity,
|
||||
});
|
||||
|
||||
// Step 6: Update SIM Inventory status to "Assigned" with assignment details
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const nested = config["mnp"];
|
||||
const hasNestedMnp = nested && typeof nested === "object";
|
||||
@ -563,69 +654,30 @@ export class SimFulfillmentService {
|
||||
return;
|
||||
}
|
||||
|
||||
const reserveNumber = this.readString(source["mnpNumber"] ?? source["reserveNumber"]);
|
||||
const reserveExpireDate = this.readString(source["mnpExpiry"] ?? source["reserveExpireDate"]);
|
||||
const account = this.readString(source["mvnoAccountNumber"] ?? source["account"]);
|
||||
const firstnameKanji = this.readString(source["portingFirstName"] ?? source["firstnameKanji"]);
|
||||
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"]);
|
||||
const result: Record<string, string> = {};
|
||||
for (const { key, sources } of SimFulfillmentService.MNP_FIELD_MAPPINGS) {
|
||||
const value = this.readString(source[sources[0]] ?? source[sources[1]]);
|
||||
if (value) result[key] = value;
|
||||
}
|
||||
|
||||
if (
|
||||
!reserveNumber &&
|
||||
!reserveExpireDate &&
|
||||
!account &&
|
||||
!firstnameKanji &&
|
||||
!lastnameKanji &&
|
||||
!firstnameZenKana &&
|
||||
!lastnameZenKana &&
|
||||
!gender &&
|
||||
!birthday
|
||||
) {
|
||||
if (Object.keys(result).length === 0) {
|
||||
this.logger.log("MNP extraction: no MNP fields found in config", {
|
||||
hasNestedMnp,
|
||||
isMnpFlag: isMnpFlag ?? "not-set",
|
||||
checkedKeys: [
|
||||
"mnpNumber",
|
||||
"reserveNumber",
|
||||
"mnpExpiry",
|
||||
"portingFirstName",
|
||||
"portingLastName",
|
||||
"portingGender",
|
||||
"portingDateOfBirth",
|
||||
],
|
||||
checkedKeys: SimFulfillmentService.MNP_FIELD_MAPPINGS.flatMap(m => m.sources),
|
||||
});
|
||||
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", {
|
||||
hasReserveNumber: !!reserveNumber,
|
||||
reserveNumberLength: reserveNumber?.length,
|
||||
hasReserveExpireDate: !!reserveExpireDate,
|
||||
hasAccount: !!account,
|
||||
hasIdentity: !!(firstnameKanji && lastnameKanji),
|
||||
hasKana: !!(firstnameZenKana && lastnameZenKana),
|
||||
gender: gender ?? "not-set",
|
||||
hasBirthday: !!birthday,
|
||||
hasReserveNumber: !!result["reserveNumber"],
|
||||
reserveNumberLength: result["reserveNumber"]?.length,
|
||||
hasReserveExpireDate: !!result["reserveExpireDate"],
|
||||
hasAccount: !!result["account"],
|
||||
hasIdentity: !!(result["firstnameKanji"] && result["lastnameKanji"]),
|
||||
hasKana: !!(result["firstnameZenKana"] && result["lastnameZenKana"]),
|
||||
gender: result["gender"] ?? "not-set",
|
||||
hasBirthday: !!result["birthday"],
|
||||
totalFieldsExtracted: Object.keys(result).length,
|
||||
});
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { Logger } from "nestjs-pino";
|
||||
import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js";
|
||||
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { normalizeToArray } from "@bff/core/utils/array.util.js";
|
||||
import {
|
||||
ValidationErrorCode,
|
||||
createValidationError,
|
||||
@ -75,11 +76,7 @@ export class InternetOrderValidator {
|
||||
try {
|
||||
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
|
||||
const productContainer = products.products?.product;
|
||||
const existing = Array.isArray(productContainer)
|
||||
? productContainer
|
||||
: productContainer
|
||||
? [productContainer]
|
||||
: [];
|
||||
const existing = normalizeToArray(productContainer);
|
||||
|
||||
// Check for active Internet products
|
||||
const activeInternetProducts = existing.filter((product: WhmcsProduct) => {
|
||||
|
||||
@ -52,11 +52,14 @@ export class WorkflowCaseManager {
|
||||
? this.buildLightningUrl("Opportunity", opportunityId)
|
||||
: null;
|
||||
|
||||
const opportunityStatus = opportunityId
|
||||
? opportunityCreated
|
||||
? "Created new opportunity for this order"
|
||||
: "Linked to existing opportunity"
|
||||
: "No opportunity linked";
|
||||
let opportunityStatus: string;
|
||||
if (!opportunityId) {
|
||||
opportunityStatus = "No opportunity linked";
|
||||
} else if (opportunityCreated) {
|
||||
opportunityStatus = "Created new opportunity for this order";
|
||||
} else {
|
||||
opportunityStatus = "Linked to existing opportunity";
|
||||
}
|
||||
|
||||
const description = this.buildDescription([
|
||||
"Order placed via Customer Portal.",
|
||||
|
||||
@ -20,10 +20,7 @@ import {
|
||||
type SimInternationalCallHistoryResponse,
|
||||
type SimSmsHistoryResponse,
|
||||
} from "@customer-portal/domain/sim";
|
||||
|
||||
// Cache-Control header constants
|
||||
const CACHE_CONTROL_PUBLIC_1H = "public, max-age=3600";
|
||||
const CACHE_CONTROL_PRIVATE_5M = "private, max-age=300";
|
||||
import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js";
|
||||
|
||||
// DTOs
|
||||
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
|
||||
@ -56,7 +53,7 @@ export class CallHistoryController {
|
||||
*/
|
||||
@Public()
|
||||
@Get("sim/call-history/available-months")
|
||||
@Header("Cache-Control", CACHE_CONTROL_PUBLIC_1H)
|
||||
@Header("Cache-Control", CACHE_CONTROL.PUBLIC_1H)
|
||||
@ZodResponse({
|
||||
description: "Get available call/SMS history months",
|
||||
type: SimHistoryAvailableMonthsResponseDto,
|
||||
@ -97,7 +94,7 @@ export class CallHistoryController {
|
||||
* Get domestic call history
|
||||
*/
|
||||
@Get(":id/sim/call-history/domestic")
|
||||
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M)
|
||||
@Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
|
||||
@ZodResponse({
|
||||
description: "Get domestic call history",
|
||||
type: SimDomesticCallHistoryResponseDto,
|
||||
@ -121,7 +118,7 @@ export class CallHistoryController {
|
||||
* Get international call history
|
||||
*/
|
||||
@Get(":id/sim/call-history/international")
|
||||
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M)
|
||||
@Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
|
||||
@ZodResponse({
|
||||
description: "Get international call history",
|
||||
type: SimInternationalCallHistoryResponseDto,
|
||||
@ -145,7 +142,7 @@ export class CallHistoryController {
|
||||
* Get 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 })
|
||||
async getSmsHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
|
||||
@ -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 { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.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 { SubscriptionValidationCoordinator } from "../../shared/index.js";
|
||||
import {
|
||||
@ -75,11 +76,7 @@ export class InternetCancellationService {
|
||||
clientid: whmcsClientId,
|
||||
});
|
||||
const productContainer = productsResponse.products?.product;
|
||||
const products = Array.isArray(productContainer)
|
||||
? productContainer
|
||||
: productContainer
|
||||
? [productContainer]
|
||||
: [];
|
||||
const products = normalizeToArray(productContainer);
|
||||
|
||||
const subscription = products.find(
|
||||
(p: { id?: number | string }) => Number(p.id) === subscriptionId
|
||||
|
||||
@ -9,6 +9,11 @@ import {
|
||||
isSimSubscription,
|
||||
} 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()
|
||||
export class SimValidationService {
|
||||
constructor(
|
||||
@ -103,11 +108,7 @@ export class SimValidationService {
|
||||
status: subscription.status,
|
||||
// Account extraction result
|
||||
extractedAccount,
|
||||
accountSource: extractedAccount
|
||||
? subscription.domain
|
||||
? "domain field"
|
||||
: "custom field or order number"
|
||||
: "NOT FOUND - check fields below",
|
||||
accountSource: getAccountSource(extractedAccount, subscription.domain),
|
||||
// All custom fields for debugging
|
||||
customFieldKeys: Object.keys(subscription.customFields || {}),
|
||||
customFields: subscription.customFields,
|
||||
|
||||
@ -47,6 +47,7 @@ import {
|
||||
type SimAvailablePlan,
|
||||
type SimCancellationPreview,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js";
|
||||
|
||||
// DTOs
|
||||
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
|
||||
@ -89,7 +90,7 @@ export class SimController {
|
||||
// ==================== Static SIM Routes (must be before :id routes) ====================
|
||||
|
||||
@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 })
|
||||
async getSimTopUpPricing() {
|
||||
const pricing = await this.simTopUpPricingService.getTopUpPricing();
|
||||
@ -97,7 +98,7 @@ export class SimController {
|
||||
}
|
||||
|
||||
@Get("sim/top-up/pricing/preview")
|
||||
@Header("Cache-Control", "public, max-age=3600")
|
||||
@Header("Cache-Control", CACHE_CONTROL.PUBLIC_1H)
|
||||
@ZodResponse({
|
||||
description: "Preview SIM top-up pricing",
|
||||
type: SimTopUpPricingPreviewResponseDto,
|
||||
@ -220,7 +221,7 @@ export class SimController {
|
||||
// ==================== Enhanced SIM Management Endpoints ====================
|
||||
|
||||
@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 })
|
||||
async getAvailablePlans(
|
||||
@Request() req: RequestWithUser,
|
||||
@ -245,7 +246,7 @@ export class SimController {
|
||||
}
|
||||
|
||||
@Get(":id/sim/cancellation-preview")
|
||||
@Header("Cache-Control", "private, max-age=60")
|
||||
@Header("Cache-Control", CACHE_CONTROL.PRIVATE_1M)
|
||||
@ZodResponse({
|
||||
description: "Get SIM cancellation preview",
|
||||
type: SimCancellationPreviewResponseDto,
|
||||
|
||||
@ -18,10 +18,7 @@ import { Validation } from "@customer-portal/domain/toolkit";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { invoiceListSchema } from "@customer-portal/domain/billing";
|
||||
|
||||
// Cache-Control header constants
|
||||
const CACHE_CONTROL_PRIVATE_5M = "private, max-age=300";
|
||||
const CACHE_CONTROL_PRIVATE_1M = "private, max-age=60";
|
||||
import { CACHE_CONTROL } from "@bff/core/constants/http.constants.js";
|
||||
|
||||
const subscriptionInvoiceQuerySchema = Validation.createPaginationSchema({
|
||||
defaultLimit: 10,
|
||||
@ -54,7 +51,7 @@ export class SubscriptionsController {
|
||||
constructor(private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator) {}
|
||||
|
||||
@Get()
|
||||
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M)
|
||||
@Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
|
||||
@ZodResponse({ description: "List subscriptions", type: SubscriptionListDto })
|
||||
async getSubscriptions(
|
||||
@Request() req: RequestWithUser,
|
||||
@ -68,21 +65,21 @@ export class SubscriptionsController {
|
||||
}
|
||||
|
||||
@Get("active")
|
||||
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M)
|
||||
@Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
|
||||
@ZodResponse({ description: "List active subscriptions", type: ActiveSubscriptionsDto })
|
||||
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
|
||||
return this.subscriptionsOrchestrator.getActiveSubscriptions(req.user.id);
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M)
|
||||
@Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
|
||||
@ZodResponse({ description: "Get subscription stats", type: SubscriptionStatsDto })
|
||||
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> {
|
||||
return this.subscriptionsOrchestrator.getSubscriptionStats(req.user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M)
|
||||
@Header("Cache-Control", CACHE_CONTROL.PRIVATE_5M)
|
||||
@ZodResponse({ description: "Get subscription", type: SubscriptionDto })
|
||||
async getSubscriptionById(
|
||||
@Request() req: RequestWithUser,
|
||||
@ -92,7 +89,7 @@ export class SubscriptionsController {
|
||||
}
|
||||
|
||||
@Get(":id/invoices")
|
||||
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_1M)
|
||||
@Header("Cache-Control", CACHE_CONTROL.PRIVATE_1M)
|
||||
@ZodResponse({ description: "Get subscription invoices", type: InvoiceListDto })
|
||||
async getSubscriptionInvoices(
|
||||
@Request() req: RequestWithUser,
|
||||
|
||||
@ -24,6 +24,14 @@ import { basename, extname } from "node:path";
|
||||
// Default fallback filename for residence card submissions
|
||||
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()
|
||||
export class ResidenceCardService {
|
||||
// eslint-disable-next-line max-params -- NestJS DI requires injecting multiple services
|
||||
@ -91,11 +99,7 @@ export class ResidenceCardService {
|
||||
const reviewedAt = normalizeSalesforceDateTimeToIsoUtc(verifiedAtRaw);
|
||||
|
||||
const reviewerNotes =
|
||||
typeof rejectionRaw === "string" && rejectionRaw.trim().length > 0
|
||||
? rejectionRaw.trim()
|
||||
: typeof noteRaw === "string" && noteRaw.trim().length > 0
|
||||
? noteRaw.trim()
|
||||
: null;
|
||||
extractNonEmptyString(rejectionRaw) ?? extractNonEmptyString(noteRaw) ?? null;
|
||||
|
||||
return residenceCardVerificationSchema.parse({
|
||||
status,
|
||||
|
||||
@ -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
|
||||
* 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) => ({
|
||||
label: s.label,
|
||||
status: index < currentStep ? "completed" : index === currentStep ? "current" : "upcoming",
|
||||
status: getStepStatus(index, currentStep),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -28,6 +28,11 @@ import {
|
||||
normalizeCycle,
|
||||
} 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 {
|
||||
defaultCurrencyCode?: string;
|
||||
defaultCurrencySymbol?: string;
|
||||
@ -193,11 +198,7 @@ export function transformWhmcsSubscriptionListResponse(
|
||||
}
|
||||
|
||||
const productContainer = parsed.products?.product;
|
||||
const products = Array.isArray(productContainer)
|
||||
? productContainer
|
||||
: productContainer
|
||||
? [productContainer]
|
||||
: [];
|
||||
const products = normalizeToArray(productContainer);
|
||||
|
||||
const subscriptions: Subscription[] = [];
|
||||
for (const product of products) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user