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);
|
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 {
|
||||||
|
|||||||
@ -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"];
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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] : [];
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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,11 +173,49 @@ export class SimFulfillmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (simType === "eSIM") {
|
if (simType === "eSIM") {
|
||||||
// eSIM activation flow
|
return this.fulfillEsim({
|
||||||
|
orderDetails,
|
||||||
|
eid,
|
||||||
|
phoneNumber,
|
||||||
|
planSku,
|
||||||
|
planName,
|
||||||
|
activationType,
|
||||||
|
scheduledAt,
|
||||||
|
mnp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (!eid || eid.length < 15) {
|
||||||
throw new SimActivationException("EID is required for eSIM and must be valid", {
|
throw new SimActivationException("EID is required for eSIM and must be valid", {
|
||||||
orderId: orderDetails.id,
|
orderId: orderDetails.id,
|
||||||
simType,
|
simType: "eSIM",
|
||||||
eidLength: eid?.length,
|
eidLength: eid?.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -188,7 +226,6 @@ export class SimFulfillmentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map product SKU to Freebit plan code (same as physical SIM path)
|
|
||||||
const planCode = mapProductToFreebitPlanCode(planSku, planName);
|
const planCode = mapProductToFreebitPlanCode(planSku, planName);
|
||||||
if (!planCode) {
|
if (!planCode) {
|
||||||
throw new SimActivationException(
|
throw new SimActivationException(
|
||||||
@ -218,10 +255,33 @@ export class SimFulfillmentService {
|
|||||||
phoneNumber,
|
phoneNumber,
|
||||||
eid,
|
eid,
|
||||||
};
|
};
|
||||||
} else {
|
}
|
||||||
// Physical SIM activation flow:
|
|
||||||
// Non-MNP: PA02-01 + PA05-05
|
private async fulfillPhysicalSim(params: {
|
||||||
// MNP: PA05-19 + PA05-05
|
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) {
|
if (!assignedPhysicalSimId) {
|
||||||
throw new SimActivationException(
|
throw new SimActivationException(
|
||||||
"Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)",
|
"Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)",
|
||||||
@ -260,7 +320,6 @@ export class SimFulfillmentService {
|
|||||||
simInventoryId: assignedPhysicalSimId,
|
simInventoryId: assignedPhysicalSimId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activate eSIM via Freebit PA05-41 API
|
* Activate eSIM via Freebit PA05-41 API
|
||||||
@ -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,40 +525,14 @@ 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) {
|
|
||||||
this.logger.log("Calling PA05-05 Voice Options Registration", {
|
|
||||||
orderId,
|
orderId,
|
||||||
account: accountPhoneNumber,
|
account: accountPhoneNumber,
|
||||||
voiceMailEnabled,
|
voiceMailEnabled,
|
||||||
callWaitingEnabled,
|
callWaitingEnabled,
|
||||||
|
contactIdentity,
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user