feat: add eligibility check flow with form, OTP, and success steps

- Implemented FormStep component for user input (name, email, address).
- Created OtpStep component for OTP verification.
- Developed SuccessStep component to display success messages based on account creation.
- Introduced eligibility-check.store for managing state throughout the eligibility check process.
- Added commitlint configuration for standardized commit messages.
- Configured knip for workspace management and project structure.
This commit is contained in:
barsa 2026-01-15 11:28:25 +09:00
parent 1d1602f5e7
commit 0f6bae840f
320 changed files with 6631 additions and 3998 deletions

1
.husky/commit-msg Executable file
View File

@ -0,0 +1 @@
pnpm commitlint --edit $1

View File

@ -6,9 +6,14 @@ Instructions for Claude Code working in this repository.
## Agent Behavior ## Agent Behavior
**Always use `pnpm`** — never use `npm`, `yarn`, or `npx`:
- Use `pnpm exec` to run local binaries (e.g., `pnpm exec prisma migrate status`)
- Use `pnpm dlx` for one-off package execution (e.g., `pnpm dlx ts-prune`)
**Do NOT** run long-running processes without explicit permission: **Do NOT** run long-running processes without explicit permission:
- `pnpm dev`, `pnpm dev:start`, `npm start`, `npm run dev` - `pnpm dev`, `pnpm dev:start`, or any dev server commands
- Any command that starts servers, watchers, or blocking processes - Any command that starts servers, watchers, or blocking processes
**Always ask first** before: **Always ask first** before:

View File

@ -54,6 +54,7 @@
"nestjs-zod": "^5.0.1", "nestjs-zod": "^5.0.1",
"p-queue": "^9.0.1", "p-queue": "^9.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"pino-http": "^11.0.0",
"prisma": "^7.1.0", "prisma": "^7.1.0",
"rate-limiter-flexible": "^9.0.0", "rate-limiter-flexible": "^9.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",

View File

@ -1,6 +1,5 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core"; import { APP_INTERCEPTOR, APP_PIPE, RouterModule } from "@nestjs/core";
import { RouterModule } from "@nestjs/core";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule"; import { ScheduleModule } from "@nestjs/schedule";
import { ZodSerializerInterceptor, ZodValidationPipe } from "nestjs-zod"; import { ZodSerializerInterceptor, ZodValidationPipe } from "nestjs-zod";

View File

@ -7,6 +7,10 @@ import helmet from "helmet";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import type { CookieOptions, Response, NextFunction, Request } from "express"; import type { CookieOptions, Response, NextFunction, Request } from "express";
import { UnifiedExceptionFilter } from "../core/http/exception.filter.js";
import { AppModule } from "../app.module.js";
/* eslint-disable @typescript-eslint/no-namespace */ /* eslint-disable @typescript-eslint/no-namespace */
declare global { declare global {
namespace Express { namespace Express {
@ -15,11 +19,6 @@ declare global {
} }
} }
} }
/* eslint-enable @typescript-eslint/no-namespace */
import { UnifiedExceptionFilter } from "../core/http/exception.filter.js";
import { AppModule } from "../app.module.js";
export async function bootstrap(): Promise<INestApplication> { export async function bootstrap(): Promise<INestApplication> {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { const app = await NestFactory.create<NestExpressApplication>(AppModule, {

View File

@ -2,7 +2,7 @@ import { resolve } from "node:path";
import type { ConfigModuleOptions } from "@nestjs/config"; import type { ConfigModuleOptions } from "@nestjs/config";
import { validate } from "./env.validation.js"; import { validate } from "./env.validation.js";
const nodeEnv = process.env.NODE_ENV || "development"; const nodeEnv = process.env["NODE_ENV"] || "development";
// pnpm sets cwd to the package directory (apps/bff) when running scripts // pnpm sets cwd to the package directory (apps/bff) when running scripts
const bffRoot = process.cwd(); const bffRoot = process.cwd();

View File

@ -12,17 +12,17 @@ export interface DevAuthConfig {
} }
export const createDevAuthConfig = (): DevAuthConfig => { export const createDevAuthConfig = (): DevAuthConfig => {
const isDevelopment = process.env.NODE_ENV !== "production"; const isDevelopment = process.env["NODE_ENV"] !== "production";
return { return {
// Disable CSRF protection in development for easier testing // Disable CSRF protection in development for easier testing
disableCsrf: isDevelopment && process.env.DISABLE_CSRF === "true", disableCsrf: isDevelopment && process.env["DISABLE_CSRF"] === "true",
// Disable rate limiting in development // Disable rate limiting in development
disableRateLimit: isDevelopment && process.env.DISABLE_RATE_LIMIT === "true", disableRateLimit: isDevelopment && process.env["DISABLE_RATE_LIMIT"] === "true",
// Disable account locking in development // Disable account locking in development
disableAccountLocking: isDevelopment && process.env.DISABLE_ACCOUNT_LOCKING === "true", disableAccountLocking: isDevelopment && process.env["DISABLE_ACCOUNT_LOCKING"] === "true",
// Enable debug logs in development // Enable debug logs in development
enableDebugLogs: isDevelopment, enableDebugLogs: isDevelopment,

View File

@ -47,11 +47,11 @@ function mapHttpStatusToErrorCode(status?: number): ErrorCodeType {
*/ */
interface ErrorContext { interface ErrorContext {
requestId: string; requestId: string;
userId?: string; userId?: string | undefined;
method: string; method: string;
path: string; path: string;
userAgent?: string; userAgent?: string | undefined;
ip?: string; ip?: string | undefined;
} }
/** /**
@ -123,7 +123,7 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
*/ */
private extractExceptionDetails(exception: HttpException): { private extractExceptionDetails(exception: HttpException): {
message: string; message: string;
code?: ErrorCodeType; code?: ErrorCodeType | undefined;
} { } {
const response = exception.getResponse(); const response = exception.getResponse();
@ -136,19 +136,21 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
const code = this.extractExplicitCode(responseObj); const code = this.extractExplicitCode(responseObj);
// Handle NestJS validation errors (array of messages) // Handle NestJS validation errors (array of messages)
if (Array.isArray(responseObj.message)) { const messageField = responseObj["message"];
const firstMessage = responseObj.message.find((m): m is string => typeof m === "string"); if (Array.isArray(messageField)) {
const firstMessage = messageField.find((m): m is string => typeof m === "string");
if (firstMessage) return { message: firstMessage, code }; if (firstMessage) return { message: firstMessage, code };
} }
// Handle standard message field // Handle standard message field
if (typeof responseObj.message === "string") { if (typeof messageField === "string") {
return { message: responseObj.message, code }; return { message: messageField, code };
} }
// Handle error field // Handle error field
if (typeof responseObj.error === "string") { const errorField = responseObj["error"];
return { message: responseObj.error, code }; if (typeof errorField === "string") {
return { message: errorField, code };
} }
return { message: exception.message, code }; return { message: exception.message, code };
@ -162,16 +164,18 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
*/ */
private extractExplicitCode(responseObj: Record<string, unknown>): ErrorCodeType | undefined { private extractExplicitCode(responseObj: Record<string, unknown>): ErrorCodeType | undefined {
// 1) Preferred: { code: "AUTH_003" } // 1) Preferred: { code: "AUTH_003" }
if (typeof responseObj.code === "string" && this.isKnownErrorCode(responseObj.code)) { const codeField = responseObj["code"];
return responseObj.code as ErrorCodeType; if (typeof codeField === "string" && this.isKnownErrorCode(codeField)) {
return codeField as ErrorCodeType;
} }
// 2) Standard API error format: { error: { code: "AUTH_003" } } // 2) Standard API error format: { error: { code: "AUTH_003" } }
const maybeError = responseObj.error; const maybeError = responseObj["error"];
if (maybeError && typeof maybeError === "object") { if (maybeError && typeof maybeError === "object") {
const errorObj = maybeError as Record<string, unknown>; const errorObj = maybeError as Record<string, unknown>;
if (typeof errorObj.code === "string" && this.isKnownErrorCode(errorObj.code)) { const errorCode = errorObj["code"];
return errorObj.code as ErrorCodeType; if (typeof errorCode === "string" && this.isKnownErrorCode(errorCode)) {
return errorCode as ErrorCodeType;
} }
} }
@ -209,7 +213,7 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
.replace(/secret[=:]\s*[^\s]+/gi, "secret=[HIDDEN]") .replace(/secret[=:]\s*[^\s]+/gi, "secret=[HIDDEN]")
.replace(/token[=:]\s*[^\s]+/gi, "token=[HIDDEN]") .replace(/token[=:]\s*[^\s]+/gi, "token=[HIDDEN]")
.replace(/key[=:]\s*[^\s]+/gi, "key=[HIDDEN]") .replace(/key[=:]\s*[^\s]+/gi, "key=[HIDDEN]")
.substring(0, 200); // Limit length .slice(0, 200); // Limit length
} }
/** /**

View File

@ -1,8 +1,8 @@
export interface RequestContextLike { export interface RequestContextLike {
headers?: Record<string, string | string[] | undefined>; headers?: Record<string, string | string[] | undefined> | undefined;
ip?: string; ip?: string | undefined;
connection?: { remoteAddress?: string }; connection?: { remoteAddress?: string | undefined } | undefined;
socket?: { remoteAddress?: string }; socket?: { remoteAddress?: string | undefined } | undefined;
} }
export function extractClientIp(request?: RequestContextLike): string { export function extractClientIp(request?: RequestContextLike): string {

View File

@ -1,86 +1,88 @@
import { Global, Module } from "@nestjs/common"; import { Global, Module } from "@nestjs/common";
import { LoggerModule } from "nestjs-pino"; import { LoggerModule } from "nestjs-pino";
import type { Options as PinoHttpOptions } from "pino-http";
const prettyLogsEnabled = const prettyLogsEnabled =
process.env.PRETTY_LOGS === "true" || process.env.NODE_ENV !== "production"; process.env["PRETTY_LOGS"] === "true" || process.env["NODE_ENV"] !== "production";
// Build pinoHttp config - extracted to avoid type issues with exactOptionalPropertyTypes
const pinoHttpConfig: PinoHttpOptions = {
level: process.env["LOG_LEVEL"] || "info",
name: process.env["APP_NAME"] || "customer-portal-bff",
/**
* Reduce noise from pino-http auto logging:
* - successful requests => debug (hidden when LOG_LEVEL=info)
* - 4xx => warn
* - 5xx / errors => error
*
* This keeps production logs focused on actionable events while still
* allowing full request logging by setting LOG_LEVEL=debug.
*/
customLogLevel: (_req, res, err) => {
if (err || (res?.statusCode && res.statusCode >= 500)) return "error";
if (res?.statusCode && res.statusCode >= 400) return "warn";
return "debug";
},
autoLogging: {
ignore: req => {
const url = req.url || "";
return (
url.includes("/health") ||
url.includes("/favicon") ||
url.includes("/_next/") ||
url.includes("/api/auth/session")
);
},
},
serializers: {
req: (req: { method?: string; url?: string; headers?: Record<string, unknown> }) => ({
method: req.method,
url: req.url,
...(process.env["NODE_ENV"] === "development" && {
headers: req.headers,
}),
}),
res: (res: { statusCode?: number }) => ({
statusCode: res.statusCode,
}),
},
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"req.body",
"res.body",
"password",
"token",
"secret",
"jwt",
"apiKey",
],
remove: true,
},
formatters: {
level: (label: string) => ({ level: label }),
bindings: () => ({}),
},
};
// Add transport only in development to avoid type issues
if (prettyLogsEnabled) {
pinoHttpConfig.transport = {
target: "pino-pretty",
options: {
colorize: true,
translateTime: false,
singleLine: true,
// keep level for coloring but drop other noisy metadata
ignore: "pid,req,res,context,name,time",
},
};
}
@Global() @Global()
@Module({ @Module({
imports: [ imports: [LoggerModule.forRoot({ pinoHttp: pinoHttpConfig })],
LoggerModule.forRoot({
pinoHttp: {
level: process.env.LOG_LEVEL || "info",
name: process.env.APP_NAME || "customer-portal-bff",
/**
* Reduce noise from pino-http auto logging:
* - successful requests => debug (hidden when LOG_LEVEL=info)
* - 4xx => warn
* - 5xx / errors => error
*
* This keeps production logs focused on actionable events while still
* allowing full request logging by setting LOG_LEVEL=debug.
*/
customLogLevel: (_req, res, err) => {
if (err || (res?.statusCode && res.statusCode >= 500)) return "error";
if (res?.statusCode && res.statusCode >= 400) return "warn";
return "debug";
},
autoLogging: {
ignore: req => {
const url = req.url || "";
return (
url.includes("/health") ||
url.includes("/favicon") ||
url.includes("/_next/") ||
url.includes("/api/auth/session")
);
},
},
serializers: {
req: (req: { method?: string; url?: string; headers?: Record<string, unknown> }) => ({
method: req.method,
url: req.url,
...(process.env.NODE_ENV === "development" && {
headers: req.headers,
}),
}),
res: (res: { statusCode?: number }) => ({
statusCode: res.statusCode,
}),
},
transport: prettyLogsEnabled
? {
target: "pino-pretty",
options: {
colorize: true,
translateTime: false,
singleLine: true,
// keep level for coloring but drop other noisy metadata
ignore: "pid,req,res,context,name,time",
},
}
: undefined,
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"req.body",
"res.body",
"password",
"token",
"secret",
"jwt",
"apiKey",
],
remove: true,
},
formatters: {
level: (label: string) => ({ level: label }),
bindings: () => ({}),
},
},
}),
],
exports: [LoggerModule], exports: [LoggerModule],
}) })
export class LoggingModule {} export class LoggingModule {}

View File

@ -31,17 +31,14 @@ type CsrfRequest = Omit<BaseExpressRequest, "cookies"> & {
*/ */
@Injectable() @Injectable()
export class CsrfMiddleware implements NestMiddleware { export class CsrfMiddleware implements NestMiddleware {
private readonly isProduction: boolean;
private readonly exemptPaths: Set<string>; private readonly exemptPaths: Set<string>;
private readonly safeMethods: Set<string>; private readonly safeMethods: Set<string>;
constructor( constructor(
private readonly csrfService: CsrfService, private readonly csrfService: CsrfService,
private readonly configService: ConfigService, _configService: ConfigService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) { ) {
this.isProduction = this.configService.get("NODE_ENV") === "production";
// Paths that don't require CSRF protection // Paths that don't require CSRF protection
this.exemptPaths = new Set([ this.exemptPaths = new Set([
"/api/auth/login", "/api/auth/login",
@ -94,7 +91,7 @@ export class CsrfMiddleware implements NestMiddleware {
return false; return false;
} }
private validateCsrfToken(req: CsrfRequest, res: Response, next: NextFunction): void { private validateCsrfToken(req: CsrfRequest, _res: Response, next: NextFunction): void {
const token = this.extractTokenFromRequest(req); const token = this.extractTokenFromRequest(req);
const secret = this.extractSecretFromCookie(req); const secret = this.extractSecretFromCookie(req);
const sessionId = req.user?.sessionId || this.extractSessionId(req); const sessionId = req.user?.sessionId || this.extractSessionId(req);

View File

@ -7,13 +7,13 @@ export interface CsrfTokenData {
token: string; token: string;
secret: string; secret: string;
expiresAt: Date; expiresAt: Date;
sessionId?: string; sessionId?: string | undefined;
userId?: string; userId?: string | undefined;
} }
export interface CsrfValidationResult { export interface CsrfValidationResult {
isValid: boolean; isValid: boolean;
reason?: string; reason?: string | undefined;
} }
export interface CsrfTokenStats { export interface CsrfTokenStats {
@ -191,7 +191,7 @@ export class CsrfService {
} }
private hashToken(token: string): string { private hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex").substring(0, 16); return crypto.createHash("sha256").update(token).digest("hex").slice(0, 16);
} }
private constantTimeEquals(a: string, b: string): boolean { private constantTimeEquals(a: string, b: string): boolean {

View File

@ -11,7 +11,7 @@ export function isErrorWithMessage(error: unknown): error is Error {
typeof error === "object" && typeof error === "object" &&
error !== null && error !== null &&
Object.hasOwn(error, "message") && Object.hasOwn(error, "message") &&
typeof (error as Record<string, unknown>).message === "string" typeof (error as Record<string, unknown>)["message"] === "string"
); );
} }
@ -19,8 +19,8 @@ export function isErrorWithMessage(error: unknown): error is Error {
* Enhanced error type with common error properties * Enhanced error type with common error properties
*/ */
interface EnhancedError extends Error { interface EnhancedError extends Error {
code?: string; code?: string | undefined;
statusCode?: number; statusCode?: number | undefined;
cause?: unknown; cause?: unknown;
} }

View File

@ -71,8 +71,10 @@ export function calculateBackoffDelay(
/** /**
* Promise-based sleep utility * Promise-based sleep utility
*/ */
export function sleep(ms: number): Promise<void> { export async function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => {
setTimeout(resolve, ms);
});
} }
/** /**

View File

@ -22,16 +22,28 @@ export enum AuditAction {
} }
export interface AuditLogData { export interface AuditLogData {
userId?: string; userId?: string | undefined;
action: AuditAction; action: AuditAction;
resource?: string; resource?: string | undefined;
details?: Record<string, unknown> | string | number | boolean | null; details?: Record<string, unknown> | string | number | boolean | null | undefined;
ipAddress?: string; ipAddress?: string | undefined;
userAgent?: string; userAgent?: string | undefined;
success?: boolean; success?: boolean | undefined;
error?: string; error?: string | undefined;
} }
/**
* Minimal request shape for audit logging.
* Compatible with Express Request but only requires the fields needed for IP/UA extraction.
* Must be compatible with RequestContextLike from request-context.util.ts.
*/
export type AuditRequest = {
headers?: Record<string, string | string[] | undefined> | undefined;
ip?: string | undefined;
connection?: { remoteAddress?: string | undefined } | undefined;
socket?: { remoteAddress?: string | undefined } | undefined;
};
@Injectable() @Injectable()
export class AuditService { export class AuditService {
constructor( constructor(
@ -41,23 +53,24 @@ export class AuditService {
async log(data: AuditLogData): Promise<void> { async log(data: AuditLogData): Promise<void> {
try { try {
await this.prisma.auditLog.create({ const createData: Parameters<typeof this.prisma.auditLog.create>[0]["data"] = {
data: { action: data.action,
userId: data.userId, success: data.success ?? true,
action: data.action, };
resource: data.resource,
details: if (data.userId !== undefined) createData.userId = data.userId;
data.details === undefined if (data.resource !== undefined) createData.resource = data.resource;
? undefined if (data.ipAddress !== undefined) createData.ipAddress = data.ipAddress;
: data.details === null if (data.userAgent !== undefined) createData.userAgent = data.userAgent;
? Prisma.JsonNull if (data.error !== undefined) createData.error = data.error;
: (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue), if (data.details !== undefined) {
ipAddress: data.ipAddress, createData.details =
userAgent: data.userAgent, data.details === null
success: data.success ?? true, ? Prisma.JsonNull
error: data.error, : (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue);
}, }
});
await this.prisma.auditLog.create({ data: createData });
} catch (error) { } catch (error) {
this.logger.error("Audit logging failed", { this.logger.error("Audit logging failed", {
errorType: error instanceof Error ? error.constructor.name : "Unknown", errorType: error instanceof Error ? error.constructor.name : "Unknown",
@ -70,12 +83,7 @@ export class AuditService {
action: AuditAction, action: AuditAction,
userId?: string, userId?: string,
details?: Record<string, unknown> | string | number | boolean | null, details?: Record<string, unknown> | string | number | boolean | null,
request?: { request?: AuditRequest,
headers?: Record<string, string | string[] | undefined>;
ip?: string;
connection?: { remoteAddress?: string };
socket?: { remoteAddress?: string };
},
success: boolean = true, success: boolean = true,
error?: string error?: string
): Promise<void> { ): Promise<void> {

View File

@ -78,22 +78,23 @@ export class CacheService {
*/ */
async delPattern(pattern: string): Promise<void> { async delPattern(pattern: string): Promise<void> {
const pipeline = this.redis.pipeline(); const pipeline = this.redis.pipeline();
let pending = 0; const state = { pending: 0 };
const flush = async () => { const flush = async () => {
if (pending === 0) { if (state.pending === 0) {
return; return;
} }
await pipeline.exec(); await pipeline.exec();
pending = 0; // eslint-disable-next-line require-atomic-updates -- flush is called sequentially, no concurrent access
state.pending = 0;
}; };
await this.scanPattern(pattern, async keys => { await this.scanPattern(pattern, async keys => {
keys.forEach(key => { for (const key of keys) {
pipeline.del(key); pipeline.del(key);
pending += 1; state.pending += 1;
}); }
if (pending >= 1000) { if (state.pending >= 1000) {
await flush(); await flush();
} }
}); });
@ -122,9 +123,9 @@ export class CacheService {
let total = 0; let total = 0;
await this.scanPattern(pattern, async keys => { await this.scanPattern(pattern, async keys => {
const pipeline = this.redis.pipeline(); const pipeline = this.redis.pipeline();
keys.forEach(key => { for (const key of keys) {
pipeline.memory("USAGE", key); pipeline.memory("USAGE", key);
}); }
const results = await pipeline.exec(); const results = await pipeline.exec();
if (!results) { if (!results) {
return; return;

View File

@ -65,7 +65,7 @@ export class DistributedLockService {
return { return {
key: lockKey, key: lockKey,
token, token,
release: () => this.release(lockKey, token), release: async () => this.release(lockKey, token),
}; };
} }
@ -183,7 +183,9 @@ export class DistributedLockService {
/** /**
* Delay helper * Delay helper
*/ */
private delay(ms: number): Promise<void> { private async delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => {
setTimeout(resolve, ms);
});
} }
} }

View File

@ -17,12 +17,11 @@ import { PrismaPg } from "@prisma/adapter-pg";
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name); private readonly logger = new Logger(PrismaService.name);
private readonly pool: Pool; private readonly pool: Pool;
private readonly instanceTag: string;
private destroyCalls = 0; private destroyCalls = 0;
private poolEnded = false; private poolEnded = false;
constructor() { constructor() {
const connectionString = process.env.DATABASE_URL; const connectionString = process.env["DATABASE_URL"];
if (!connectionString) { if (!connectionString) {
throw new Error("DATABASE_URL environment variable is required"); throw new Error("DATABASE_URL environment variable is required");
} }
@ -43,7 +42,6 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
super({ adapter }); super({ adapter });
this.pool = pool; this.pool = pool;
this.instanceTag = `${process.pid}:${Date.now()}`;
} }
async onModuleInit() { async onModuleInit() {

View File

@ -24,8 +24,8 @@ export interface DistributedTransactionResult<
TStepResults extends StepResultMap = StepResultMap, TStepResults extends StepResultMap = StepResultMap,
> { > {
success: boolean; success: boolean;
data?: TData; data?: TData | undefined;
error?: string; error?: string | undefined;
duration: number; duration: number;
stepsExecuted: number; stepsExecuted: number;
stepsRolledBack: number; stepsRolledBack: number;
@ -254,7 +254,7 @@ export class DistributedTransactionService {
databaseOperation, databaseOperation,
{ {
description: `${options.description} - Database Operations`, description: `${options.description} - Database Operations`,
timeout: options.timeout, ...(options.timeout !== undefined && { timeout: options.timeout }),
} }
); );
@ -305,7 +305,7 @@ export class DistributedTransactionService {
databaseOperation, databaseOperation,
{ {
description: `${options.description} - Database Operations`, description: `${options.description} - Database Operations`,
timeout: options.timeout, ...(options.timeout !== undefined && { timeout: options.timeout }),
} }
); );
@ -442,6 +442,6 @@ export class DistributedTransactionService {
} }
private generateTransactionId(): string { private generateTransactionId(): string {
return `dtx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; return `dtx_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
} }
} }

View File

@ -28,36 +28,41 @@ export interface TransactionOptions {
* Maximum time to wait for transaction to complete (ms) * Maximum time to wait for transaction to complete (ms)
* Default: 30 seconds * Default: 30 seconds
*/ */
timeout?: number; timeout?: number | undefined;
/** /**
* Maximum number of retry attempts on serialization failures * Maximum number of retry attempts on serialization failures
* Default: 3 * Default: 3
*/ */
maxRetries?: number; maxRetries?: number | undefined;
/** /**
* Custom isolation level for the transaction * Custom isolation level for the transaction
* Default: ReadCommitted * Default: ReadCommitted
*/ */
isolationLevel?: "ReadUncommitted" | "ReadCommitted" | "RepeatableRead" | "Serializable"; isolationLevel?:
| "ReadUncommitted"
| "ReadCommitted"
| "RepeatableRead"
| "Serializable"
| undefined;
/** /**
* Description of the transaction for logging * Description of the transaction for logging
*/ */
description?: string; description?: string | undefined;
/** /**
* Whether to automatically rollback external operations on database rollback * Whether to automatically rollback external operations on database rollback
* Default: true * Default: true
*/ */
autoRollback?: boolean; autoRollback?: boolean | undefined;
} }
export interface TransactionResult<T> { export interface TransactionResult<T> {
success: boolean; success: boolean;
data?: T; data?: T | undefined;
error?: string; error?: string | undefined;
duration: number; duration: number;
operationsCount: number; operationsCount: number;
rollbacksExecuted: number; rollbacksExecuted: number;
@ -289,8 +294,10 @@ export class TransactionService {
// Execute rollbacks in reverse order (LIFO) // Execute rollbacks in reverse order (LIFO)
for (let i = context.rollbackActions.length - 1; i >= 0; i--) { for (let i = context.rollbackActions.length - 1; i >= 0; i--) {
const rollbackAction = context.rollbackActions[i];
if (!rollbackAction) continue;
try { try {
await context.rollbackActions[i](); await rollbackAction();
rollbacksExecuted++; rollbacksExecuted++;
this.logger.debug(`Rollback ${i + 1} completed [${context.id}]`); this.logger.debug(`Rollback ${i + 1} completed [${context.id}]`);
} catch (rollbackError) { } catch (rollbackError) {
@ -321,11 +328,13 @@ export class TransactionService {
} }
private async delay(ms: number): Promise<void> { private async delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => {
setTimeout(resolve, ms);
});
} }
private generateTransactionId(): string { private generateTransactionId(): string {
return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; return `tx_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
} }
/** /**

View File

@ -19,8 +19,8 @@ export interface ProviderSendOptions {
export interface SendGridErrorDetail { export interface SendGridErrorDetail {
message: string; message: string;
field?: string; field?: string | undefined;
help?: string; help?: string | undefined;
} }
export interface ParsedSendGridError { export interface ParsedSendGridError {
@ -215,8 +215,8 @@ export class SendGridEmailProvider implements OnModuleInit {
private maskEmail(email: string | string[]): string | string[] { private maskEmail(email: string | string[]): string | string[] {
const mask = (e: string): string => { const mask = (e: string): string => {
const [local, domain] = e.split("@"); const [local, domain] = e.split("@");
if (!domain) return "***"; if (!domain || !local) return "***";
const maskedLocal = local.length > 2 ? `${local[0]}***${local[local.length - 1]}` : "***"; const maskedLocal = local.length > 2 ? `${local[0]}***${local.at(-1)}` : "***";
return `${maskedLocal}@${domain}`; return `${maskedLocal}@${domain}`;
}; };

View File

@ -17,7 +17,7 @@ function parseRedisConnection(redisUrl: string) {
host: url.hostname, host: url.hostname,
port: Number(url.port || (isTls ? 6380 : 6379)), port: Number(url.port || (isTls ? 6380 : 6379)),
password: url.password || undefined, password: url.password || undefined,
...(db !== undefined ? { db } : {}), ...(db === undefined ? {} : { db }),
...(isTls ? { tls: {} } : {}), ...(isTls ? { tls: {} } : {}),
} as Record<string, unknown>; } as Record<string, unknown>;
} catch { } catch {

View File

@ -11,7 +11,7 @@ export type DegradationReason = "rate-limit" | "usage-threshold" | "queue-pressu
export interface SalesforceDegradationSnapshot { export interface SalesforceDegradationSnapshot {
degraded: boolean; degraded: boolean;
reason: DegradationReason | null; reason: DegradationReason | null;
cooldownExpiresAt?: Date; cooldownExpiresAt?: Date | undefined;
usagePercent: number; usagePercent: number;
} }
@ -118,8 +118,7 @@ export class SalesforceQueueDegradationService {
this.activateDegradeWindow("usage-threshold"); this.activateDegradeWindow("usage-threshold");
} }
const threshold = this.usageWarningLevels const threshold = [...this.usageWarningLevels]
.slice()
.reverse() .reverse()
.find(level => usagePercent >= level && level > this.highestUsageWarningIssued); .find(level => usagePercent >= level && level > this.highestUsageWarningIssued);

View File

@ -8,16 +8,16 @@ export interface SalesforceRouteMetricsInternal {
label: string; label: string;
totalRequests: number; totalRequests: number;
failedRequests: number; failedRequests: number;
lastSuccessTime?: Date; lastSuccessTime?: Date | undefined;
lastErrorTime?: Date; lastErrorTime?: Date | undefined;
} }
export interface SalesforceRouteMetricsSnapshot { export interface SalesforceRouteMetricsSnapshot {
totalRequests: number; totalRequests: number;
failedRequests: number; failedRequests: number;
successRate: number; successRate: number;
lastSuccessTime?: Date; lastSuccessTime?: Date | undefined;
lastErrorTime?: Date; lastErrorTime?: Date | undefined;
} }
export interface SalesforceQueueMetricsData { export interface SalesforceQueueMetricsData {
@ -29,9 +29,9 @@ export interface SalesforceQueueMetricsData {
averageWaitTime: number; averageWaitTime: number;
averageExecutionTime: number; averageExecutionTime: number;
dailyApiUsage: number; dailyApiUsage: number;
lastRequestTime?: Date; lastRequestTime?: Date | undefined;
lastErrorTime?: Date; lastErrorTime?: Date | undefined;
lastRateLimitTime?: Date; lastRateLimitTime?: Date | undefined;
} }
@Injectable() @Injectable()
@ -52,7 +52,10 @@ export class SalesforceQueueMetricsService {
dailyApiUsage: 0, dailyApiUsage: 0,
}; };
constructor(@Inject(Logger) private readonly logger: Logger) {} constructor(@Inject(Logger) _logger: Logger) {
// Logger available for future use in metrics logging
void _logger;
}
/** /**
* Get current metrics data * Get current metrics data

View File

@ -22,13 +22,13 @@ export interface SalesforceQueueMetrics {
averageWaitTime: number; averageWaitTime: number;
averageExecutionTime: number; averageExecutionTime: number;
dailyApiUsage: number; dailyApiUsage: number;
lastRequestTime?: Date; lastRequestTime?: Date | undefined;
lastErrorTime?: Date; lastErrorTime?: Date | undefined;
lastRateLimitTime?: Date; lastRateLimitTime?: Date | undefined;
dailyApiLimit?: number; dailyApiLimit?: number | undefined;
dailyUsagePercent?: number; dailyUsagePercent?: number | undefined;
routeBreakdown?: Record<string, SalesforceRouteMetricsSnapshot>; routeBreakdown?: Record<string, SalesforceRouteMetricsSnapshot> | undefined;
degradation?: SalesforceDegradationSnapshot; degradation?: SalesforceDegradationSnapshot | undefined;
} }
export interface SalesforceRequestOptions { export interface SalesforceRequestOptions {
@ -480,7 +480,9 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
error: lastError.message, error: lastError.message,
}); });
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => {
setTimeout(resolve, delay);
});
} }
} }

View File

@ -35,10 +35,10 @@ export interface WhmcsQueueMetrics {
} }
export interface WhmcsRequestOptions { export interface WhmcsRequestOptions {
priority?: number; // Higher number = higher priority (0-10) priority?: number | undefined; // Higher number = higher priority (0-10)
timeout?: number; // Request timeout in ms timeout?: number | undefined; // Request timeout in ms
retryAttempts?: number; // Number of retry attempts retryAttempts?: number | undefined; // Number of retry attempts
retryDelay?: number; // Base delay between retries in ms retryDelay?: number | undefined; // Base delay between retries in ms
} }
/** /**

View File

@ -72,7 +72,7 @@ export class RealtimePubSubService implements OnModuleInit, OnModuleDestroy {
} }
} }
publish(message: RealtimePubSubMessage): Promise<number> { async publish(message: RealtimePubSubMessage): Promise<number> {
return this.redis.publish(this.CHANNEL, JSON.stringify(message)); return this.redis.publish(this.CHANNEL, JSON.stringify(message));
} }

View File

@ -109,7 +109,7 @@ export class RealtimeService {
} }
const evt = this.buildMessage(message.event, message.data); const evt = this.buildMessage(message.event, message.data);
set.forEach(observer => { for (const observer of set) {
try { try {
observer.next(evt); observer.next(evt);
} catch (error) { } catch (error) {
@ -118,7 +118,7 @@ export class RealtimeService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
}); });
} }
}); }
} }
private buildMessage<TEvent extends string>(event: TEvent, data: unknown): MessageEvent { private buildMessage<TEvent extends string>(event: TEvent, data: unknown): MessageEvent {

View File

@ -35,8 +35,8 @@ export class FreebitAccountService {
const config = this.auth.getConfig(); const config = this.auth.getConfig();
const configured = config.detailsEndpoint || "/master/getAcnt/"; const configured = config.detailsEndpoint || "/master/getAcnt/";
const candidates = Array.from( const candidates = [
new Set([ ...new Set([
configured, configured,
configured.replace(/\/$/, ""), configured.replace(/\/$/, ""),
"/master/getAcnt/", "/master/getAcnt/",
@ -53,8 +53,8 @@ export class FreebitAccountService {
"/mvno/getInfo", "/mvno/getInfo",
"/master/getDetail/", "/master/getDetail/",
"/master/getDetail", "/master/getDetail",
]) ]),
); ];
let response: FreebitAccountDetailsResponse | undefined; let response: FreebitAccountDetailsResponse | undefined;
let lastError: unknown; let lastError: unknown;

View File

@ -80,9 +80,9 @@ export class FreebitAuthService {
} }
const data = (await response.json()) as FreebitAuthResponse; const data = (await response.json()) as FreebitAuthResponse;
const resultCode = data?.resultCode != null ? String(data.resultCode).trim() : undefined; const resultCode = data?.resultCode == null ? undefined : String(data.resultCode).trim();
const statusCode = const statusCode =
data?.status?.statusCode != null ? String(data.status.statusCode).trim() : undefined; data?.status?.statusCode == null ? undefined : String(data.status.statusCode).trim();
if (resultCode !== "100") { if (resultCode !== "100") {
throw new FreebitError( throw new FreebitError(

View File

@ -30,7 +30,7 @@ export class FreebitCancellationService {
await this.rateLimiter.executeWithSpacing(account, "cancellation", async () => { await this.rateLimiter.executeWithSpacing(account, "cancellation", async () => {
const request: Omit<FreebitCancelPlanRequest, "authKey"> = { const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
account, account,
runTime: scheduledAt, ...(scheduledAt !== undefined && { runTime: scheduledAt }),
}; };
this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, { this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, {
@ -75,7 +75,7 @@ export class FreebitCancellationService {
const request: Omit<FreebitCancelAccountRequest, "authKey"> = { const request: Omit<FreebitCancelAccountRequest, "authKey"> = {
kind: "MVNO", kind: "MVNO",
account, account,
runDate, ...(runDate !== undefined && { runDate }),
}; };
this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, { this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, {

View File

@ -57,7 +57,7 @@ export class FreebitClientService {
}); });
if (!response.ok) { if (!response.ok) {
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env["NODE_ENV"] === "production";
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response); const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
this.logger.error("Freebit API HTTP error", { this.logger.error("Freebit API HTTP error", {
url, url,
@ -78,7 +78,7 @@ export class FreebitClientService {
const statusCode = this.normalizeResultCode(responseData.status?.statusCode); const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") { if (resultCode && resultCode !== "100") {
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env["NODE_ENV"] === "production";
this.logger.warn("Freebit API returned error response", { this.logger.warn("Freebit API returned error response", {
url, url,
resultCode, resultCode,
@ -166,7 +166,7 @@ export class FreebitClientService {
const statusCode = this.normalizeResultCode(responseData.status?.statusCode); const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") { if (resultCode && resultCode !== "100") {
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env["NODE_ENV"] === "production";
this.logger.error("Freebit API returned error result code", { this.logger.error("Freebit API returned error result code", {
url, url,
resultCode, resultCode,

View File

@ -2,9 +2,9 @@
* Custom error class for Freebit API errors * Custom error class for Freebit API errors
*/ */
export class FreebitError extends Error { export class FreebitError extends Error {
public readonly resultCode?: string | number; public readonly resultCode?: string | number | undefined;
public readonly statusCode?: string | number; public readonly statusCode?: string | number | undefined;
public readonly statusMessage?: string; public readonly statusMessage?: string | undefined;
constructor( constructor(
message: string, message: string,

View File

@ -86,7 +86,7 @@ export class FreebitEsimService {
account, account,
eid: newEid, eid: newEid,
addKind: "R", addKind: "R",
planCode: options.planCode, ...(options.planCode !== undefined && { planCode: options.planCode }),
}; };
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>( await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(

View File

@ -187,11 +187,12 @@ export class FreebitMapperService {
throw new Error("No traffic data in response"); throw new Error("No traffic data in response");
} }
const todayUsageKb = parseInt(response.traffic.today, 10) || 0; const todayUsageKb = Number.parseInt(response.traffic.today, 10) || 0;
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({ const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0], date:
usageKb: parseInt(usage, 10) || 0, new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0] ?? "",
usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100, usageKb: Number.parseInt(usage, 10) || 0,
usageMb: Math.round(((Number.parseInt(usage, 10) || 0) / 1024) * 100) / 100,
})); }));
return { return {
@ -216,8 +217,8 @@ export class FreebitMapperService {
totalAdditions: Number(response.total) || 0, totalAdditions: Number(response.total) || 0,
additionCount: Number(response.count) || 0, additionCount: Number(response.count) || 0,
history: response.quotaHistory.map(item => ({ history: response.quotaHistory.map(item => ({
quotaKb: parseInt(item.quota, 10), quotaKb: Number.parseInt(item.quota, 10),
quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100, quotaMb: Math.round((Number.parseInt(item.quota, 10) / 1024) * 100) / 100,
addedDate: item.date, addedDate: item.date,
expiryDate: item.expire, expiryDate: item.expire,
campaignCode: item.quotaCode, campaignCode: item.quotaCode,
@ -259,9 +260,9 @@ export class FreebitMapperService {
return null; return null;
} }
const year = parseInt(dateString.substring(0, 4), 10); const year = Number.parseInt(dateString.slice(0, 4), 10);
const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed const month = Number.parseInt(dateString.slice(4, 6), 10) - 1; // Month is 0-indexed
const day = parseInt(dateString.substring(6, 8), 10); const day = Number.parseInt(dateString.slice(6, 8), 10);
return new Date(year, month, day); return new Date(year, month, day);
} }

View File

@ -111,7 +111,7 @@ export class FreebitOperationsService {
account: string, account: string,
newPlanCode: string, newPlanCode: string,
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {} options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
): Promise<{ ipv4?: string; ipv6?: string }> { ): Promise<{ ipv4?: string | undefined; ipv6?: string | undefined }> {
const normalizedAccount = this.normalizeAccount(account); const normalizedAccount = this.normalizeAccount(account);
return this.planService.changeSimPlan(normalizedAccount, newPlanCode, options); return this.planService.changeSimPlan(normalizedAccount, newPlanCode, options);
} }

View File

@ -37,7 +37,7 @@ export class FreebitPlanService {
account: string, account: string,
newPlanCode: string, newPlanCode: string,
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {} options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
): Promise<{ ipv4?: string; ipv6?: string }> { ): Promise<{ ipv4?: string | undefined; ipv6?: string | undefined }> {
try { try {
return await this.rateLimiter.executeWithSpacing(account, "plan", async () => { return await this.rateLimiter.executeWithSpacing(account, "plan", async () => {
// First, get current SIM details to log for debugging // First, get current SIM details to log for debugging
@ -127,17 +127,17 @@ export class FreebitPlanService {
}; };
if (error instanceof Error) { if (error instanceof Error) {
errorDetails.errorName = error.name; errorDetails["errorName"] = error.name;
errorDetails.errorMessage = error.message; errorDetails["errorMessage"] = error.message;
if ("resultCode" in error) { if ("resultCode" in error) {
errorDetails.resultCode = error.resultCode; errorDetails["resultCode"] = (error as Record<string, unknown>)["resultCode"];
} }
if ("statusCode" in error) { if ("statusCode" in error) {
errorDetails.statusCode = error.statusCode; errorDetails["statusCode"] = (error as Record<string, unknown>)["statusCode"];
} }
if ("statusMessage" in error) { if ("statusMessage" in error) {
errorDetails.statusMessage = error.statusMessage; errorDetails["statusMessage"] = (error as Record<string, unknown>)["statusMessage"];
} }
} }

View File

@ -155,7 +155,7 @@ export class FreebitRateLimiterService {
const parsed: OperationTimestamps = {}; const parsed: OperationTimestamps = {};
for (const [field, value] of Object.entries(raw)) { for (const [field, value] of Object.entries(raw)) {
const num = Number(value); const num = Number(value);
if (!isNaN(num)) { if (!Number.isNaN(num)) {
parsed[field as keyof OperationTimestamps] = num; parsed[field as keyof OperationTimestamps] = num;
} }
} }

View File

@ -62,8 +62,8 @@ export class FreebitUsageService {
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = { const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
account, account,
quota: quotaKb, quota: quotaKb,
quotaCode: options.campaignCode, ...(options.campaignCode !== undefined && { quotaCode: options.campaignCode }),
expire: options.expiryDate, ...(options.expiryDate !== undefined && { expire: options.expiryDate }),
}; };
const scheduled = !!options.scheduledAt; const scheduled = !!options.scheduledAt;

View File

@ -14,9 +14,9 @@ import type {
} from "../interfaces/freebit.types.js"; } from "../interfaces/freebit.types.js";
export interface VoiceFeatures { export interface VoiceFeatures {
voiceMailEnabled?: boolean; voiceMailEnabled?: boolean | undefined;
callWaitingEnabled?: boolean; callWaitingEnabled?: boolean | undefined;
internationalRoamingEnabled?: boolean; internationalRoamingEnabled?: boolean | undefined;
} }
/** /**

View File

@ -37,9 +37,9 @@ interface ConfigValidationError {
* Japan Post API error response format * Japan Post API error response format
*/ */
interface JapanPostErrorResponse { interface JapanPostErrorResponse {
request_id?: string; request_id?: string | undefined;
error_code?: string; error_code?: string | undefined;
message?: string; message?: string | undefined;
} }
@Injectable() @Injectable()

View File

@ -7,7 +7,7 @@ import {
type SalesforceOrderFieldMap, type SalesforceOrderFieldMap,
} from "@customer-portal/domain/orders/providers"; } from "@customer-portal/domain/orders/providers";
const unique = (values: string[]): string[] => Array.from(new Set(values)); const unique = (values: string[]): string[] => [...new Set(values)];
const SECTION_PREFIX: Record<keyof SalesforceOrderFieldMap, string> = { const SECTION_PREFIX: Record<keyof SalesforceOrderFieldMap, string> = {
order: "ORDER", order: "ORDER",

View File

@ -80,7 +80,7 @@ export class AccountEventsSubscriber implements OnModuleInit {
*/ */
private async handleAccountEvent( private async handleAccountEvent(
channel: string, channel: string,
subscription: { topicName?: string }, _subscription: { topicName?: string },
callbackType: string, callbackType: string,
data: unknown data: unknown
): Promise<void> { ): Promise<void> {
@ -118,10 +118,10 @@ export class AccountEventsSubscriber implements OnModuleInit {
if (this.accountNotificationHandler && (eligibilityStatus || verificationStatus)) { if (this.accountNotificationHandler && (eligibilityStatus || verificationStatus)) {
void this.accountNotificationHandler.processAccountEvent({ void this.accountNotificationHandler.processAccountEvent({
accountId, accountId,
eligibilityStatus, eligibilityStatus: eligibilityStatus ?? null,
eligibilityValue: undefined, eligibilityValue: null,
verificationStatus, verificationStatus: verificationStatus ?? null,
verificationRejectionMessage: rejectionMessage, verificationRejectionMessage: rejectionMessage ?? null,
}); });
} }
} }

View File

@ -63,7 +63,7 @@ export class CaseEventsSubscriber implements OnModuleInit {
*/ */
private async handleCaseEvent( private async handleCaseEvent(
channel: string, channel: string,
subscription: { topicName?: string }, _subscription: { topicName?: string },
callbackType: string, callbackType: string,
data: unknown data: unknown
): Promise<void> { ): Promise<void> {

View File

@ -127,7 +127,7 @@ export class CatalogCdcSubscriber implements OnModuleInit {
private async handlePricebookEvent( private async handlePricebookEvent(
channel: string, channel: string,
subscription: { topicName?: string }, _subscription: { topicName?: string },
callbackType: string, callbackType: string,
data: unknown data: unknown
): Promise<void> { ): Promise<void> {

View File

@ -111,7 +111,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
private async handleOrderEvent( private async handleOrderEvent(
channel: string, channel: string,
subscription: { topicName?: string }, _subscription: { topicName?: string },
callbackType: string, callbackType: string,
data: unknown data: unknown
): Promise<void> { ): Promise<void> {
@ -141,7 +141,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
this.logger.debug("Order CDC: only internal field changes; skipping cache invalidation", { this.logger.debug("Order CDC: only internal field changes; skipping cache invalidation", {
channel, channel,
orderId, orderId,
changedFields: Array.from(changedFields), changedFields: [...changedFields],
}); });
return; return;
} }
@ -150,7 +150,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
channel, channel,
orderId, orderId,
accountId, accountId,
changedFields: Array.from(changedFields), changedFields: [...changedFields],
}); });
await this.invalidateOrderCaches(orderId, accountId); await this.invalidateOrderCaches(orderId, accountId);
@ -222,7 +222,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
private async handleOrderItemEvent( private async handleOrderItemEvent(
channel: string, channel: string,
subscription: { topicName?: string }, _subscription: { topicName?: string },
callbackType: string, callbackType: string,
data: unknown data: unknown
): Promise<void> { ): Promise<void> {
@ -245,7 +245,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
this.logger.debug("OrderItem CDC: only internal field changes; skipping", { this.logger.debug("OrderItem CDC: only internal field changes; skipping", {
channel, channel,
orderId, orderId,
changedFields: Array.from(changedFields), changedFields: [...changedFields],
}); });
return; return;
} }
@ -253,7 +253,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
this.logger.log("OrderItem CDC: invalidating order cache", { this.logger.log("OrderItem CDC: invalidating order cache", {
channel, channel,
orderId, orderId,
changedFields: Array.from(changedFields), changedFields: [...changedFields],
}); });
await this.invalidateOrderCaches(orderId, accountId); await this.invalidateOrderCaches(orderId, accountId);
@ -291,7 +291,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
return true; return true;
} }
const customerFacingChanges = Array.from(changedFields).filter( const customerFacingChanges = [...changedFields].filter(
field => !INTERNAL_ORDER_FIELDS.has(field) field => !INTERNAL_ORDER_FIELDS.has(field)
); );
@ -303,7 +303,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
return true; // Safe default return true; // Safe default
} }
const customerFacingChanges = Array.from(changedFields).filter( const customerFacingChanges = [...changedFields].filter(
field => !INTERNAL_ORDER_ITEM_FIELDS.has(field) field => !INTERNAL_ORDER_ITEM_FIELDS.has(field)
); );

View File

@ -98,8 +98,8 @@ export function extractChangedFields(payload: Record<string, unknown> | undefine
// CDC provides changed fields in different formats depending on API version // CDC provides changed fields in different formats depending on API version
const changedFieldsArray = const changedFieldsArray =
(payload.changedFields as string[] | undefined) || (payload["changedFields"] as string[] | undefined) ||
(payload.changeOrigin as { changedFields?: string[] })?.changedFields || (payload["changeOrigin"] as { changedFields?: string[] } | undefined)?.changedFields ||
[]; [];
return new Set([ return new Set([

View File

@ -45,7 +45,7 @@ export class SalesforceService implements OnModuleInit {
} }
} catch (error) { } catch (error) {
const nodeEnv = const nodeEnv =
this.configService.get<string>("NODE_ENV") || process.env.NODE_ENV || "development"; this.configService.get<string>("NODE_ENV") || process.env["NODE_ENV"] || "development";
const isProd = nodeEnv === "production"; const isProd = nodeEnv === "production";
if (isProd) { if (isProd) {
this.logger.error("Failed to initialize Salesforce connection"); this.logger.error("Failed to initialize Salesforce connection");
@ -63,15 +63,19 @@ export class SalesforceService implements OnModuleInit {
return this.accountService.findByCustomerNumber(customerNumber); return this.accountService.findByCustomerNumber(customerNumber);
} }
async findAccountWithDetailsByCustomerNumber( async findAccountWithDetailsByCustomerNumber(customerNumber: string): Promise<{
customerNumber: string id: string;
): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> { WH_Account__c?: string | null | undefined;
Name?: string | null | undefined;
} | null> {
return this.accountService.findWithDetailsByCustomerNumber(customerNumber); return this.accountService.findWithDetailsByCustomerNumber(customerNumber);
} }
async getAccountDetails( async getAccountDetails(accountId: string): Promise<{
accountId: string id: string;
): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> { WH_Account__c?: string | null | undefined;
Name?: string | null | undefined;
} | null> {
return this.accountService.getAccountDetails(accountId); return this.accountService.getAccountDetails(accountId);
} }

View File

@ -82,7 +82,7 @@ export class OpportunityResolutionService {
async resolveForOrderPlacement(params: { async resolveForOrderPlacement(params: {
accountId: string | null; accountId: string | null;
orderType: OrderTypeValue; orderType: OrderTypeValue;
existingOpportunityId?: string; existingOpportunityId?: string | undefined;
}): Promise<string | null> { }): Promise<string | null> {
if (!params.accountId) return null; if (!params.accountId) return null;

View File

@ -110,9 +110,9 @@ export class OpportunityMutationService {
if (error && typeof error === "object") { if (error && typeof error === "object") {
const err = error as Record<string, unknown>; const err = error as Record<string, unknown>;
if (err.errorCode) errorDetails.errorCode = err.errorCode; if (err["errorCode"]) errorDetails["errorCode"] = err["errorCode"];
if (err.fields) errorDetails.fields = err.fields; if (err["fields"]) errorDetails["fields"] = err["fields"];
if (err.message) errorDetails.rawMessage = err.message; if (err["message"]) errorDetails["rawMessage"] = err["message"];
} }
this.logger.error(errorDetails, "Failed to create Opportunity"); this.logger.error(errorDetails, "Failed to create Opportunity");
@ -246,7 +246,7 @@ export class OpportunityMutationService {
// ========================================================================== // ==========================================================================
private calculateCloseDate( private calculateCloseDate(
productType: OpportunityProductTypeValue, _productType: OpportunityProductTypeValue,
stage: OpportunityStageValue stage: OpportunityStageValue
): string { ): string {
const today = new Date(); const today = new Date();

View File

@ -39,15 +39,15 @@ export interface InternetCancellationStatusResult {
stage: OpportunityStageValue; stage: OpportunityStageValue;
isPending: boolean; isPending: boolean;
isComplete: boolean; isComplete: boolean;
scheduledEndDate?: string; scheduledEndDate?: string | undefined;
rentalReturnStatus?: LineReturnStatusValue; rentalReturnStatus?: LineReturnStatusValue | undefined;
} }
export interface SimCancellationStatusResult { export interface SimCancellationStatusResult {
stage: OpportunityStageValue; stage: OpportunityStageValue;
isPending: boolean; isPending: boolean;
isComplete: boolean; isComplete: boolean;
scheduledEndDate?: string; scheduledEndDate?: string | undefined;
} }
@Injectable() @Injectable()
@ -125,8 +125,8 @@ export class OpportunityQueryService {
if (error && typeof error === "object") { if (error && typeof error === "object") {
const err = error as Record<string, unknown>; const err = error as Record<string, unknown>;
if (err.errorCode) errorDetails.errorCode = err.errorCode; if (err["errorCode"]) errorDetails["errorCode"] = err["errorCode"];
if (err.message) errorDetails.rawMessage = err.message; if (err["message"]) errorDetails["rawMessage"] = err["message"];
} }
this.logger.error(errorDetails, "Failed to find open Opportunity"); this.logger.error(errorDetails, "Failed to find open Opportunity");

View File

@ -40,7 +40,7 @@ export class SalesforceAccountService {
private readonly portalSourceField: string; private readonly portalSourceField: string;
private readonly portalLastSignedInField: string; private readonly portalLastSignedInField: string;
private readonly whmcsAccountField: string; private readonly whmcsAccountField: string;
private readonly personAccountRecordTypeId?: string; private readonly personAccountRecordTypeId?: string | undefined;
/** /**
* Find Salesforce account by customer number (SF_Account_No__c field) * Find Salesforce account by customer number (SF_Account_No__c field)
@ -65,8 +65,8 @@ export class SalesforceAccountService {
async findWithDetailsByCustomerNumber(customerNumber: string): Promise<{ async findWithDetailsByCustomerNumber(customerNumber: string): Promise<{
id: string; id: string;
Name?: string | null; Name?: string | null | undefined;
WH_Account__c?: string | null; WH_Account__c?: string | null | undefined;
} | null> { } | null> {
const validCustomerNumber = customerNumberSchema.parse(customerNumber); const validCustomerNumber = customerNumberSchema.parse(customerNumber);
@ -98,9 +98,11 @@ export class SalesforceAccountService {
* Get account details including WH_Account__c field * Get account details including WH_Account__c field
* Used in signup workflow to check if account is already linked to WHMCS * Used in signup workflow to check if account is already linked to WHMCS
*/ */
async getAccountDetails( async getAccountDetails(accountId: string): Promise<{
accountId: string id: string;
): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> { WH_Account__c?: string | null | undefined;
Name?: string | null | undefined;
} | null> {
const validAccountId = salesforceIdSchema.parse(accountId); const validAccountId = salesforceIdSchema.parse(accountId);
try { try {
@ -196,9 +198,9 @@ export class SalesforceAccountService {
this.logger.debug("Person Account creation payload", { this.logger.debug("Person Account creation payload", {
recordTypeId: personAccountRecordTypeId, recordTypeId: personAccountRecordTypeId,
hasFirstName: Boolean(accountPayload.FirstName), hasFirstName: Boolean(accountPayload["FirstName"]),
hasLastName: Boolean(accountPayload.LastName), hasLastName: Boolean(accountPayload["LastName"]),
hasPersonEmail: Boolean(accountPayload.PersonEmail), hasPersonEmail: Boolean(accountPayload["PersonEmail"]),
}); });
try { try {
@ -249,18 +251,18 @@ export class SalesforceAccountService {
if (error && typeof error === "object") { if (error && typeof error === "object") {
const err = error as Record<string, unknown>; const err = error as Record<string, unknown>;
// jsforce errors often have these properties // jsforce errors often have these properties
if (err.errorCode) errorDetails.errorCode = err.errorCode; if (err["errorCode"]) errorDetails["errorCode"] = err["errorCode"];
if (err.fields) errorDetails.fields = err.fields; if (err["fields"]) errorDetails["fields"] = err["fields"];
if (err.message) errorDetails.rawMessage = err.message; if (err["message"]) errorDetails["rawMessage"] = err["message"];
// Check for nested error objects // Check for nested error objects
if (err.error) errorDetails.nestedError = err.error; if (err["error"]) errorDetails["nestedError"] = err["error"];
// Log all enumerable properties in development // Log all enumerable properties in development
if (process.env.NODE_ENV !== "production") { if (process.env["NODE_ENV"] !== "production") {
errorDetails.allProperties = Object.keys(err); errorDetails["allProperties"] = Object.keys(err);
try { try {
errorDetails.fullError = JSON.stringify(error, null, 2); errorDetails["fullError"] = JSON.stringify(error, null, 2);
} catch { } catch {
errorDetails.fullError = errorMessage; errorDetails["fullError"] = errorMessage;
} }
} }
} }
@ -298,10 +300,14 @@ export class SalesforceAccountService {
); );
} }
const recordTypeId = recordTypeQuery.records[0].Id; const record = recordTypeQuery.records[0];
if (!record) {
throw new Error("Person Account RecordType record not found");
}
const recordTypeId = record.Id;
this.logger.debug("Found Person Account RecordType", { this.logger.debug("Found Person Account RecordType", {
recordTypeId, recordTypeId,
name: recordTypeQuery.records[0].Name, name: record.Name,
}); });
return recordTypeId; return recordTypeId;
} catch (error) { } catch (error) {

View File

@ -19,19 +19,15 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { CASE_FIELDS } from "../constants/field-maps.js"; import { CASE_FIELDS } from "../constants/field-maps.js";
import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
import type { SupportCase, CaseMessageList } from "@customer-portal/domain/support"; import type { SupportCase, CaseMessageList } from "@customer-portal/domain/support";
import type {
SalesforceCaseRecord,
SalesforceEmailMessage,
SalesforceCaseComment,
} from "@customer-portal/domain/support/providers";
import { import {
type SalesforceCaseRecord,
type SalesforceEmailMessage,
type SalesforceCaseComment,
type SalesforceCaseOrigin,
SALESFORCE_CASE_ORIGIN, SALESFORCE_CASE_ORIGIN,
SALESFORCE_CASE_STATUS, SALESFORCE_CASE_STATUS,
SALESFORCE_CASE_PRIORITY, SALESFORCE_CASE_PRIORITY,
toSalesforcePriority, toSalesforcePriority,
type SalesforceCaseOrigin,
} from "@customer-portal/domain/support/providers";
import {
buildCaseByIdQuery, buildCaseByIdQuery,
buildCaseSelectFields, buildCaseSelectFields,
buildCasesForAccountQuery, buildCasesForAccountQuery,
@ -65,11 +61,11 @@ export interface CreateCaseParams {
/** Case origin - determines visibility and routing */ /** Case origin - determines visibility and routing */
origin: SalesforceCaseOrigin; origin: SalesforceCaseOrigin;
/** Priority (defaults to Medium) */ /** Priority (defaults to Medium) */
priority?: string; priority?: string | undefined;
/** Optional Salesforce Contact ID */ /** Optional Salesforce Contact ID */
contactId?: string; contactId?: string | undefined;
/** Optional Opportunity ID for workflow cases */ /** Optional Opportunity ID for workflow cases */
opportunityId?: string; opportunityId?: string | undefined;
} }
/** /**
@ -80,9 +76,9 @@ export interface CreateWebCaseParams {
description: string; description: string;
suppliedEmail: string; suppliedEmail: string;
suppliedName: string; suppliedName: string;
suppliedPhone?: string; suppliedPhone?: string | undefined;
origin?: string; origin?: string | undefined;
priority?: string; priority?: string | undefined;
} }
@Injectable() @Injectable()

View File

@ -60,8 +60,8 @@ function normalizePrivateKeyInput(
} }
export interface SalesforceSObjectApi { export interface SalesforceSObjectApi {
create: (data: Record<string, unknown>) => Promise<{ id?: string }>; create: (data: Record<string, unknown>) => Promise<{ id?: string | undefined }>;
update?: (data: Record<string, unknown> & { Id: string }) => Promise<unknown>; update?: ((data: Record<string, unknown> & { Id: string }) => Promise<unknown>) | undefined;
} }
@Injectable() @Injectable()
@ -114,7 +114,7 @@ export class SalesforceConnection {
private async performConnect(): Promise<void> { private async performConnect(): Promise<void> {
const nodeEnv = const nodeEnv =
this.configService.get<string>("NODE_ENV") || process.env.NODE_ENV || "development"; this.configService.get<string>("NODE_ENV") || process.env["NODE_ENV"] || "development";
const isProd = nodeEnv === "production"; const isProd = nodeEnv === "production";
const authTimeout = this.configService.get<number>("SF_AUTH_TIMEOUT_MS", 30000); const authTimeout = this.configService.get<number>("SF_AUTH_TIMEOUT_MS", 30000);
const startTime = Date.now(); const startTime = Date.now();

View File

@ -21,11 +21,14 @@ import {
type OpportunityStageValue, type OpportunityStageValue,
type OpportunityProductTypeValue, type OpportunityProductTypeValue,
type CancellationOpportunityData, type CancellationOpportunityData,
type LineReturnStatusValue,
type CreateOpportunityRequest, type CreateOpportunityRequest,
type OpportunityRecord, type OpportunityRecord,
} from "@customer-portal/domain/opportunity"; } from "@customer-portal/domain/opportunity";
import { OpportunityQueryService } from "./opportunity/opportunity-query.service.js"; import {
OpportunityQueryService,
type InternetCancellationStatusResult,
type SimCancellationStatusResult,
} from "./opportunity/opportunity-query.service.js";
import { OpportunityCancellationService } from "./opportunity/opportunity-cancellation.service.js"; import { OpportunityCancellationService } from "./opportunity/opportunity-cancellation.service.js";
import { OpportunityMutationService } from "./opportunity/opportunity-mutation.service.js"; import { OpportunityMutationService } from "./opportunity/opportunity-mutation.service.js";
import type { import type {
@ -147,63 +150,45 @@ export class SalesforceOpportunityService {
/** /**
* Get Internet cancellation status by WHMCS Service ID * Get Internet cancellation status by WHMCS Service ID
*/ */
async getInternetCancellationStatus(whmcsServiceId: number): Promise<{ async getInternetCancellationStatus(
stage: OpportunityStageValue; whmcsServiceId: number
isPending: boolean; ): Promise<InternetCancellationStatusResult | null> {
isComplete: boolean;
scheduledEndDate?: string;
rentalReturnStatus?: LineReturnStatusValue;
} | null> {
return this.queryService.getInternetCancellationStatus(whmcsServiceId); return this.queryService.getInternetCancellationStatus(whmcsServiceId);
} }
/** /**
* Get Internet cancellation status by Opportunity ID * Get Internet cancellation status by Opportunity ID
*/ */
async getInternetCancellationStatusByOpportunityId(opportunityId: string): Promise<{ async getInternetCancellationStatusByOpportunityId(
stage: OpportunityStageValue; opportunityId: string
isPending: boolean; ): Promise<InternetCancellationStatusResult | null> {
isComplete: boolean;
scheduledEndDate?: string;
rentalReturnStatus?: LineReturnStatusValue;
} | null> {
return this.queryService.getInternetCancellationStatusByOpportunityId(opportunityId); return this.queryService.getInternetCancellationStatusByOpportunityId(opportunityId);
} }
/** /**
* Get SIM cancellation status by WHMCS Service ID * Get SIM cancellation status by WHMCS Service ID
*/ */
async getSimCancellationStatus(whmcsServiceId: number): Promise<{ async getSimCancellationStatus(
stage: OpportunityStageValue; whmcsServiceId: number
isPending: boolean; ): Promise<SimCancellationStatusResult | null> {
isComplete: boolean;
scheduledEndDate?: string;
} | null> {
return this.queryService.getSimCancellationStatus(whmcsServiceId); return this.queryService.getSimCancellationStatus(whmcsServiceId);
} }
/** /**
* Get SIM cancellation status by Opportunity ID * Get SIM cancellation status by Opportunity ID
*/ */
async getSimCancellationStatusByOpportunityId(opportunityId: string): Promise<{ async getSimCancellationStatusByOpportunityId(
stage: OpportunityStageValue; opportunityId: string
isPending: boolean; ): Promise<SimCancellationStatusResult | null> {
isComplete: boolean;
scheduledEndDate?: string;
} | null> {
return this.queryService.getSimCancellationStatusByOpportunityId(opportunityId); return this.queryService.getSimCancellationStatusByOpportunityId(opportunityId);
} }
/** /**
* @deprecated Use getInternetCancellationStatus or getSimCancellationStatus * @deprecated Use getInternetCancellationStatus or getSimCancellationStatus
*/ */
async getCancellationStatus(whmcsServiceId: number): Promise<{ async getCancellationStatus(
stage: OpportunityStageValue; whmcsServiceId: number
isPending: boolean; ): Promise<InternetCancellationStatusResult | null> {
isComplete: boolean;
scheduledEndDate?: string;
rentalReturnStatus?: LineReturnStatusValue;
} | null> {
return this.queryService.getInternetCancellationStatus(whmcsServiceId); return this.queryService.getInternetCancellationStatus(whmcsServiceId);
} }

View File

@ -16,7 +16,7 @@ export interface SftpConfig {
* The value is compared against ssh2's `hostHash: "sha256"` output. * The value is compared against ssh2's `hostHash: "sha256"` output.
* If you paste an OpenSSH-style fingerprint like "SHA256:xxxx", the "SHA256:" prefix is ignored. * If you paste an OpenSSH-style fingerprint like "SHA256:xxxx", the "SHA256:" prefix is ignored.
*/ */
hostKeySha256?: string; hostKeySha256?: string | undefined;
} }
@Injectable() @Injectable()

View File

@ -248,14 +248,14 @@ export class WhmcsCacheService {
async invalidateUserCache(userId: string): Promise<void> { async invalidateUserCache(userId: string): Promise<void> {
try { try {
const patterns = [ const patterns = [
`${this.cacheConfigs.invoices.prefix}:${userId}:*`, `${this.cacheConfigs["invoices"]?.prefix}:${userId}:*`,
`${this.cacheConfigs.invoice.prefix}:${userId}:*`, `${this.cacheConfigs["invoice"]?.prefix}:${userId}:*`,
`${this.cacheConfigs.subscriptions.prefix}:${userId}:*`, `${this.cacheConfigs["subscriptions"]?.prefix}:${userId}:*`,
`${this.cacheConfigs.subscription.prefix}:${userId}:*`, `${this.cacheConfigs["subscription"]?.prefix}:${userId}:*`,
`${this.cacheConfigs.subscriptionInvoicesAll.prefix}:${userId}:*`, `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`,
]; ];
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern))); await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern)));
this.logger.log(`Invalidated all cache for user ${userId}`); this.logger.log(`Invalidated all cache for user ${userId}`);
} catch (error) { } catch (error) {
@ -292,7 +292,7 @@ export class WhmcsCacheService {
.filter(config => config.tags.includes(tag)) .filter(config => config.tags.includes(tag))
.map(config => `${config.prefix}:*`); .map(config => `${config.prefix}:*`);
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern))); await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern)));
this.logger.log(`Invalidated cache by tag: ${tag}`); this.logger.log(`Invalidated cache by tag: ${tag}`);
} catch (error) { } catch (error) {
@ -308,8 +308,8 @@ export class WhmcsCacheService {
async invalidateInvoice(userId: string, invoiceId: number): Promise<void> { async invalidateInvoice(userId: string, invoiceId: number): Promise<void> {
try { try {
const specificKey = this.buildInvoiceKey(userId, invoiceId); const specificKey = this.buildInvoiceKey(userId, invoiceId);
const listPattern = `${this.cacheConfigs.invoices.prefix}:${userId}:*`; const listPattern = `${this.cacheConfigs["invoices"]?.prefix}:${userId}:*`;
const subscriptionInvoicesPattern = `${this.cacheConfigs.subscriptionInvoicesAll.prefix}:${userId}:*`; const subscriptionInvoicesPattern = `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`;
await Promise.all([ await Promise.all([
this.cacheService.del(specificKey), this.cacheService.del(specificKey),
@ -432,6 +432,10 @@ export class WhmcsCacheService {
private async set<T>(key: string, data: T, configKey: string): Promise<void> { private async set<T>(key: string, data: T, configKey: string): Promise<void> {
try { try {
const config = this.cacheConfigs[configKey]; const config = this.cacheConfigs[configKey];
if (!config) {
this.logger.warn(`Cache config not found for key ${configKey}`);
return;
}
await this.cacheService.set(key, data, config.ttl); await this.cacheService.set(key, data, config.ttl);
this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`); this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`);
} catch (error) { } catch (error) {
@ -443,28 +447,28 @@ export class WhmcsCacheService {
* Build cache key for invoices list * Build cache key for invoices list
*/ */
private buildInvoicesKey(userId: string, page: number, limit: number, status?: string): string { private buildInvoicesKey(userId: string, page: number, limit: number, status?: string): string {
return `${this.cacheConfigs.invoices.prefix}:${userId}:${page}:${limit}:${status || "all"}`; return `${this.cacheConfigs["invoices"]?.prefix}:${userId}:${page}:${limit}:${status || "all"}`;
} }
/** /**
* Build cache key for individual invoice * Build cache key for individual invoice
*/ */
private buildInvoiceKey(userId: string, invoiceId: number): string { private buildInvoiceKey(userId: string, invoiceId: number): string {
return `${this.cacheConfigs.invoice.prefix}:${userId}:${invoiceId}`; return `${this.cacheConfigs["invoice"]?.prefix}:${userId}:${invoiceId}`;
} }
/** /**
* Build cache key for subscriptions list * Build cache key for subscriptions list
*/ */
private buildSubscriptionsKey(userId: string): string { private buildSubscriptionsKey(userId: string): string {
return `${this.cacheConfigs.subscriptions.prefix}:${userId}`; return `${this.cacheConfigs["subscriptions"]?.prefix}:${userId}`;
} }
/** /**
* Build cache key for individual subscription * Build cache key for individual subscription
*/ */
private buildSubscriptionKey(userId: string, subscriptionId: number): string { private buildSubscriptionKey(userId: string, subscriptionId: number): string {
return `${this.cacheConfigs.subscription.prefix}:${userId}:${subscriptionId}`; return `${this.cacheConfigs["subscription"]?.prefix}:${userId}:${subscriptionId}`;
} }
/** /**
@ -476,35 +480,35 @@ export class WhmcsCacheService {
page: number, page: number,
limit: number limit: number
): string { ): string {
return `${this.cacheConfigs.subscriptionInvoices.prefix}:${userId}:${subscriptionId}:${page}:${limit}`; return `${this.cacheConfigs["subscriptionInvoices"]?.prefix}:${userId}:${subscriptionId}:${page}:${limit}`;
} }
/** /**
* Build cache key for full subscription invoices list * Build cache key for full subscription invoices list
*/ */
private buildSubscriptionInvoicesAllKey(userId: string, subscriptionId: number): string { private buildSubscriptionInvoicesAllKey(userId: string, subscriptionId: number): string {
return `${this.cacheConfigs.subscriptionInvoicesAll.prefix}:${userId}:${subscriptionId}`; return `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:${subscriptionId}`;
} }
/** /**
* Build cache key for client data * Build cache key for client data
*/ */
private buildClientKey(clientId: number): string { private buildClientKey(clientId: number): string {
return `${this.cacheConfigs.client.prefix}:${clientId}`; return `${this.cacheConfigs["client"]?.prefix}:${clientId}`;
} }
/** /**
* Build cache key for client email mapping * Build cache key for client email mapping
*/ */
private buildClientEmailKey(email: string): string { private buildClientEmailKey(email: string): string {
return `${this.cacheConfigs.clientEmail.prefix}:${email.toLowerCase()}`; return `${this.cacheConfigs["clientEmail"]?.prefix}:${email.toLowerCase()}`;
} }
/** /**
* Build cache key for payment methods * Build cache key for payment methods
*/ */
private buildPaymentMethodsKey(userId: string): string { private buildPaymentMethodsKey(userId: string): string {
return `${this.cacheConfigs.paymentMethods.prefix}:${userId}`; return `${this.cacheConfigs["paymentMethods"]?.prefix}:${userId}`;
} }
/** /**
@ -528,7 +532,7 @@ export class WhmcsCacheService {
async clearAllCache(): Promise<void> { async clearAllCache(): Promise<void> {
try { try {
const patterns = Object.values(this.cacheConfigs).map(config => `${config.prefix}:*`); const patterns = Object.values(this.cacheConfigs).map(config => `${config.prefix}:*`);
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern))); await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern)));
this.logger.warn("Cleared all WHMCS cache"); this.logger.warn("Cleared all WHMCS cache");
} catch (error) { } catch (error) {
this.logger.error("Failed to clear all WHMCS cache", { error: extractErrorMessage(error) }); this.logger.error("Failed to clear all WHMCS cache", { error: extractErrorMessage(error) });

View File

@ -93,7 +93,7 @@ export class WhmcsConfigService {
const value = this.configService.get<string>(key); const value = this.configService.get<string>(key);
if (!value) return defaultValue; if (!value) return defaultValue;
const parsed = parseInt(value, 10); const parsed = Number.parseInt(value, 10);
return isNaN(parsed) ? defaultValue : parsed; return Number.isNaN(parsed) ? defaultValue : parsed;
} }
} }

View File

@ -26,8 +26,8 @@ import type {
WhmcsUpdateInvoiceResponse, WhmcsUpdateInvoiceResponse,
WhmcsCapturePaymentResponse, WhmcsCapturePaymentResponse,
} from "@customer-portal/domain/billing/providers"; } from "@customer-portal/domain/billing/providers";
import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments/providers";
import type { import type {
WhmcsGetPayMethodsParams,
WhmcsPaymentMethodListResponse, WhmcsPaymentMethodListResponse,
WhmcsPaymentGatewayListResponse, WhmcsPaymentGatewayListResponse,
} from "@customer-portal/domain/payments/providers"; } from "@customer-portal/domain/payments/providers";

View File

@ -128,7 +128,7 @@ export class WhmcsHttpClientService {
if (!response.ok) { if (!response.ok) {
// Do NOT include response body in thrown error messages (could contain sensitive/PII and // Do NOT include response body in thrown error messages (could contain sensitive/PII and
// would propagate into unified exception logs). If needed, emit a short snippet only in dev. // would propagate into unified exception logs). If needed, emit a short snippet only in dev.
if (process.env.NODE_ENV !== "production") { if (process.env["NODE_ENV"] !== "production") {
const snippet = responseText?.slice(0, 300); const snippet = responseText?.slice(0, 300);
if (snippet) { if (snippet) {
this.logger.debug(`WHMCS non-OK response body snippet [${action}]`, { this.logger.debug(`WHMCS non-OK response body snippet [${action}]`, {
@ -182,9 +182,9 @@ export class WhmcsHttpClientService {
private appendFormParam(formData: URLSearchParams, key: string, value: unknown): void { private appendFormParam(formData: URLSearchParams, key: string, value: unknown): void {
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach((entry, index) => { for (const [index, entry] of value.entries()) {
this.appendFormParam(formData, `${key}[${index}]`, entry); this.appendFormParam(formData, `${key}[${index}]`, entry);
}); }
return; return;
} }
@ -234,11 +234,11 @@ export class WhmcsHttpClientService {
try { try {
parsedResponse = JSON.parse(responseText); parsedResponse = JSON.parse(responseText);
} catch (parseError) { } catch (parseError) {
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env["NODE_ENV"] === "production";
this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, { this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, {
...(isProd ...(isProd
? { responseTextLength: responseText.length } ? { responseTextLength: responseText.length }
: { responseText: responseText.substring(0, 500) }), : { responseText: responseText.slice(0, 500) }),
parseError: extractErrorMessage(parseError), parseError: extractErrorMessage(parseError),
params: redactForLogs(params), params: redactForLogs(params),
}); });
@ -247,12 +247,12 @@ export class WhmcsHttpClientService {
// Validate basic response structure // Validate basic response structure
if (!this.isWhmcsResponse(parsedResponse)) { if (!this.isWhmcsResponse(parsedResponse)) {
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env["NODE_ENV"] === "production";
this.logger.error(`WHMCS API returned invalid response structure [${action}]`, { this.logger.error(`WHMCS API returned invalid response structure [${action}]`, {
responseType: typeof parsedResponse, responseType: typeof parsedResponse,
...(isProd ...(isProd
? { responseTextLength: responseText.length } ? { responseTextLength: responseText.length }
: { responseText: responseText.substring(0, 500) }), : { responseText: responseText.slice(0, 500) }),
params: redactForLogs(params), params: redactForLogs(params),
}); });
throw new Error("Invalid response structure from WHMCS API"); throw new Error("Invalid response structure from WHMCS API");
@ -308,7 +308,7 @@ export class WhmcsHttpClientService {
} }
const record = value as Record<string, unknown>; const record = value as Record<string, unknown>;
const rawResult = record.result; const rawResult = record["result"];
return rawResult === "success" || rawResult === "error"; return rawResult === "success" || rawResult === "error";
} }

View File

@ -2,19 +2,19 @@ export interface WhmcsApiConfig {
baseUrl: string; baseUrl: string;
identifier: string; identifier: string;
secret: string; secret: string;
timeout?: number; timeout?: number | undefined;
retryAttempts?: number; retryAttempts?: number | undefined;
retryDelay?: number; retryDelay?: number | undefined;
} }
export interface WhmcsRequestOptions { export interface WhmcsRequestOptions {
timeout?: number; timeout?: number | undefined;
retryAttempts?: number; retryAttempts?: number | undefined;
retryDelay?: number; retryDelay?: number | undefined;
/** /**
* If true, the request will jump the queue and execute immediately * If true, the request will jump the queue and execute immediately
*/ */
highPriority?: boolean; highPriority?: boolean | undefined;
} }
export interface WhmcsRetryConfig { export interface WhmcsRetryConfig {
@ -29,6 +29,6 @@ export interface WhmcsConnectionStats {
successfulRequests: number; successfulRequests: number;
failedRequests: number; failedRequests: number;
averageResponseTime: number; averageResponseTime: number;
lastRequestTime?: Date; lastRequestTime?: Date | undefined;
lastErrorTime?: Date; lastErrorTime?: Date | undefined;
} }

View File

@ -114,13 +114,14 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
const response = (await this.connectionService.getCurrencies()) as WhmcsCurrenciesResponse; const response = (await this.connectionService.getCurrencies()) as WhmcsCurrenciesResponse;
// Check if response has currencies data (success case) or error fields // Check if response has currencies data (success case) or error fields
if (response.result === "success" || (response.currencies && !response.error)) { if (response.result === "success" || (response.currencies && !response["error"])) {
// Parse the WHMCS response format into currency objects // Parse the WHMCS response format into currency objects
this.currencies = this.parseWhmcsCurrenciesResponse(response); this.currencies = this.parseWhmcsCurrenciesResponse(response);
if (this.currencies.length > 0) { if (this.currencies.length > 0) {
// Set first currency as default (WHMCS typically returns the primary currency first) // Set first currency as default (WHMCS typically returns the primary currency first)
this.defaultCurrency = this.currencies[0]; const firstCurrency = this.currencies[0];
this.defaultCurrency = firstCurrency ?? null;
this.logger.log(`Loaded ${this.currencies.length} currencies from WHMCS`, { this.logger.log(`Loaded ${this.currencies.length} currencies from WHMCS`, {
defaultCurrency: this.defaultCurrency?.code, defaultCurrency: this.defaultCurrency?.code,
@ -134,13 +135,13 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
} else { } else {
this.logger.error("WHMCS GetCurrencies returned error", { this.logger.error("WHMCS GetCurrencies returned error", {
result: response?.result, result: response?.result,
message: response?.message, message: response?.["message"],
error: response?.error, error: response?.["error"],
errorcode: response?.errorcode, errorcode: response?.["errorcode"],
fullResponse: JSON.stringify(response, null, 2), fullResponse: JSON.stringify(response, null, 2),
}); });
throw new WhmcsOperationException( throw new WhmcsOperationException(
`WHMCS GetCurrencies error: ${response?.message || response?.error || "Unknown error"}`, `WHMCS GetCurrencies error: ${response?.["message"] || response?.["error"] || "Unknown error"}`,
{ operation: "getCurrencies" } { operation: "getCurrencies" }
); );
} }
@ -171,7 +172,7 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
for (const currencyData of currencyArray) { for (const currencyData of currencyArray) {
const currency: Currency = { const currency: Currency = {
id: parseInt(String(currencyData.id)) || 0, id: Number.parseInt(String(currencyData.id)) || 0,
code: String(currencyData.code || ""), code: String(currencyData.code || ""),
prefix: String(currencyData.prefix || ""), prefix: String(currencyData.prefix || ""),
suffix: String(currencyData.suffix || ""), suffix: String(currencyData.suffix || ""),
@ -194,14 +195,15 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
const currencyIndices = currencyKeys const currencyIndices = currencyKeys
.map(key => { .map(key => {
const match = key.match(/currencies\[currency\]\[(\d+)\]\[id\]/); const match = key.match(/currencies\[currency\]\[(\d+)\]\[id\]/);
return match ? parseInt(match[1], 10) : null; const indexStr = match?.[1];
return indexStr === undefined ? null : Number.parseInt(indexStr, 10);
}) })
.filter((index): index is number => index !== null); .filter((index): index is number => index !== null);
// Build currency objects from the flat response // Build currency objects from the flat response
for (const index of currencyIndices) { for (const index of currencyIndices) {
const currency: Currency = { const currency: Currency = {
id: parseInt(String(response[`currencies[currency][${index}][id]`])) || 0, id: Number.parseInt(String(response[`currencies[currency][${index}][id]`])) || 0,
code: String(response[`currencies[currency][${index}][code]`] || ""), code: String(response[`currencies[currency][${index}][code]`] || ""),
prefix: String(response[`currencies[currency][${index}][prefix]`] || ""), prefix: String(response[`currencies[currency][${index}][prefix]`] || ""),
suffix: String(response[`currencies[currency][${index}][suffix]`] || ""), suffix: String(response[`currencies[currency][${index}][suffix]`] || ""),

View File

@ -14,8 +14,6 @@ import type {
WhmcsCreateInvoiceParams, WhmcsCreateInvoiceParams,
WhmcsUpdateInvoiceParams, WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams, WhmcsCapturePaymentParams,
} from "@customer-portal/domain/billing/providers";
import type {
WhmcsInvoiceListResponse, WhmcsInvoiceListResponse,
WhmcsInvoiceResponse, WhmcsInvoiceResponse,
WhmcsCreateInvoiceResponse, WhmcsCreateInvoiceResponse,
@ -66,7 +64,7 @@ export class WhmcsInvoiceService {
limitnum: limit, limitnum: limit,
orderby: "date", orderby: "date",
order: "DESC", order: "DESC",
...(status && { status: status as WhmcsGetInvoicesParams["status"] }), ...(status ? { status: status as WhmcsGetInvoicesParams["status"] } : {}),
}; };
const response: WhmcsInvoiceListResponse = await this.connectionService.getInvoices(params); const response: WhmcsInvoiceListResponse = await this.connectionService.getInvoices(params);
@ -118,6 +116,7 @@ export class WhmcsInvoiceService {
for (let i = 0; i < batches.length; i++) { for (let i = 0; i < batches.length; i++) {
const batch = batches[i]; const batch = batches[i];
if (!batch) continue;
// Process batch in parallel // Process batch in parallel
const batchResults = await Promise.all( const batchResults = await Promise.all(
@ -309,10 +308,10 @@ export class WhmcsInvoiceService {
status: "Unpaid", status: "Unpaid",
sendnotification: false, // Don't send email notification automatically sendnotification: false, // Don't send email notification automatically
duedate: dueDateStr, duedate: dueDateStr,
notes: params.notes,
itemdescription1: params.description, itemdescription1: params.description,
itemamount1: params.amount, itemamount1: params.amount,
itemtaxed1: false, // No tax for data top-ups for now itemtaxed1: false, // No tax for data top-ups for now
...(params.notes === undefined ? {} : { notes: params.notes }),
}; };
const response: WhmcsCreateInvoiceResponse = const response: WhmcsCreateInvoiceResponse =
@ -372,9 +371,11 @@ export class WhmcsInvoiceService {
const whmcsParams: WhmcsUpdateInvoiceParams = { const whmcsParams: WhmcsUpdateInvoiceParams = {
invoiceid: params.invoiceId, invoiceid: params.invoiceId,
status: statusForUpdate, ...(statusForUpdate === undefined ? {} : { status: statusForUpdate }),
duedate: params.dueDate ? params.dueDate.toISOString().split("T")[0] : undefined, ...(params.dueDate === undefined
notes: params.notes, ? {}
: { duedate: params.dueDate.toISOString().split("T")[0] }),
...(params.notes === undefined ? {} : { notes: params.notes }),
}; };
const response: WhmcsUpdateInvoiceResponse = const response: WhmcsUpdateInvoiceResponse =
@ -394,7 +395,7 @@ export class WhmcsInvoiceService {
return { return {
success: true, success: true,
message: response.message, ...(response.message === undefined ? {} : { message: response.message }),
}; };
} catch (error) { } catch (error) {
this.logger.error(`Failed to update invoice ${params.invoiceId}`, { this.logger.error(`Failed to update invoice ${params.invoiceId}`, {
@ -436,7 +437,9 @@ export class WhmcsInvoiceService {
return { return {
success: true, success: true,
transactionId: response.transactionid, ...(response.transactionid === undefined
? {}
: { transactionId: response.transactionid }),
}; };
} else { } else {
this.logger.warn(`Payment capture failed for invoice ${params.invoiceId}`, { this.logger.warn(`Payment capture failed for invoice ${params.invoiceId}`, {

View File

@ -10,8 +10,8 @@ import type {
WhmcsAddOrderResponse, WhmcsAddOrderResponse,
WhmcsOrderResult, WhmcsOrderResult,
} from "@customer-portal/domain/orders/providers"; } from "@customer-portal/domain/orders/providers";
import { buildWhmcsAddOrderPayload } from "@customer-portal/domain/orders/providers";
import { import {
buildWhmcsAddOrderPayload,
whmcsAddOrderResponseSchema, whmcsAddOrderResponseSchema,
whmcsAcceptOrderResponseSchema, whmcsAcceptOrderResponseSchema,
} from "@customer-portal/domain/orders/providers"; } from "@customer-portal/domain/orders/providers";
@ -47,14 +47,16 @@ export class WhmcsOrderService {
this.logger.debug("Built WHMCS AddOrder payload", { this.logger.debug("Built WHMCS AddOrder payload", {
clientId: params.clientId, clientId: params.clientId,
productCount: Array.isArray(addOrderPayload.pid) ? addOrderPayload.pid.length : 0, productCount: Array.isArray(addOrderPayload["pid"])
pids: addOrderPayload.pid, ? (addOrderPayload["pid"] as unknown[]).length
quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added : 0,
billingCycles: addOrderPayload.billingcycle, pids: addOrderPayload["pid"],
hasConfigOptions: Boolean(addOrderPayload.configoptions), quantities: addOrderPayload["qty"], // CRITICAL: Must be included for products to be added
hasCustomFields: Boolean(addOrderPayload.customfields), billingCycles: addOrderPayload["billingcycle"],
promoCode: addOrderPayload.promocode, hasConfigOptions: Boolean(addOrderPayload["configoptions"]),
paymentMethod: addOrderPayload.paymentmethod, hasCustomFields: Boolean(addOrderPayload["customfields"]),
promoCode: addOrderPayload["promocode"],
paymentMethod: addOrderPayload["paymentmethod"],
}); });
// Call WHMCS AddOrder API // Call WHMCS AddOrder API
@ -104,7 +106,7 @@ export class WhmcsOrderService {
sfOrderId: params.sfOrderId, sfOrderId: params.sfOrderId,
itemCount: params.items.length, itemCount: params.items.length,
// Include first 100 chars of error stack for debugging // Include first 100 chars of error stack for debugging
errorStack: error instanceof Error ? error.stack?.substring(0, 100) : undefined, errorStack: error instanceof Error ? error.stack?.slice(0, 100) : undefined,
}); });
throw error; throw error;
} }
@ -163,7 +165,7 @@ export class WhmcsOrderService {
orderId, orderId,
sfOrderId, sfOrderId,
// Include first 100 chars of error stack for debugging // Include first 100 chars of error stack for debugging
errorStack: error instanceof Error ? error.stack?.substring(0, 100) : undefined, errorStack: error instanceof Error ? error.stack?.slice(0, 100) : undefined,
}); });
throw error; throw error;
} }
@ -179,7 +181,8 @@ export class WhmcsOrderService {
id: orderId.toString(), id: orderId.toString(),
})) as Record<string, unknown>; })) as Record<string, unknown>;
return (response.orders as { order?: Record<string, unknown>[] })?.order?.[0] || null; const orders = response["orders"] as { order?: Record<string, unknown>[] } | undefined;
return orders?.order?.[0] ?? null;
} catch (error) { } catch (error) {
this.logger.error("Failed to get WHMCS order details", { this.logger.error("Failed to get WHMCS order details", {
error: extractErrorMessage(error), error: extractErrorMessage(error),
@ -231,17 +234,17 @@ export class WhmcsOrderService {
this.logger.debug("Built WHMCS AddOrder payload", { this.logger.debug("Built WHMCS AddOrder payload", {
clientId: params.clientId, clientId: params.clientId,
productCount: params.items.length, productCount: params.items.length,
pids: payload.pid, pids: payload["pid"],
billingCycles: payload.billingcycle, billingCycles: payload["billingcycle"],
hasConfigOptions: !!payload.configoptions, hasConfigOptions: !!payload["configoptions"],
hasCustomFields: !!payload.customfields, hasCustomFields: !!payload["customfields"],
}); });
return payload as Record<string, unknown>; return payload as Record<string, unknown>;
} }
private toWhmcsOrderResult(response: WhmcsAddOrderResponse): WhmcsOrderResult { private toWhmcsOrderResult(response: WhmcsAddOrderResponse): WhmcsOrderResult {
const orderId = parseInt(String(response.orderid), 10); const orderId = Number.parseInt(String(response.orderid), 10);
if (!orderId || Number.isNaN(orderId)) { if (!orderId || Number.isNaN(orderId)) {
throw new WhmcsOperationException("WHMCS AddOrder did not return valid order ID", { throw new WhmcsOperationException("WHMCS AddOrder did not return valid order ID", {
response, response,
@ -250,7 +253,7 @@ export class WhmcsOrderService {
return { return {
orderId, orderId,
invoiceId: response.invoiceid ? parseInt(String(response.invoiceid), 10) : undefined, invoiceId: response.invoiceid ? Number.parseInt(String(response.invoiceid), 10) : undefined,
serviceIds: this.parseDelimitedIds(response.serviceids), serviceIds: this.parseDelimitedIds(response.serviceids),
addonIds: this.parseDelimitedIds(response.addonids), addonIds: this.parseDelimitedIds(response.addonids),
domainIds: this.parseDelimitedIds(response.domainids), domainIds: this.parseDelimitedIds(response.domainids),
@ -264,7 +267,7 @@ export class WhmcsOrderService {
return value return value
.toString() .toString()
.split(",") .split(",")
.map(entry => parseInt(entry.trim(), 10)) .map(entry => Number.parseInt(entry.trim(), 10))
.filter(id => !Number.isNaN(id)); .filter(id => !Number.isNaN(id));
} }
} }

View File

@ -16,8 +16,8 @@ import { transformWhmcsCatalogProductsResponse } from "@customer-portal/domain/s
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js"; import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer/providers"; import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer/providers";
import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments/providers";
import type { import type {
WhmcsGetPayMethodsParams,
WhmcsPaymentMethod, WhmcsPaymentMethod,
WhmcsPaymentMethodListResponse, WhmcsPaymentMethodListResponse,
WhmcsPaymentGateway, WhmcsPaymentGateway,
@ -270,7 +270,7 @@ export class WhmcsPaymentService {
* Debug helper: log only the host of the SSO URL (never the token) in non-production. * Debug helper: log only the host of the SSO URL (never the token) in non-production.
*/ */
private debugLogRedirectHost(url: string): void { private debugLogRedirectHost(url: string): void {
if (process.env.NODE_ENV === "production") return; if (process.env["NODE_ENV"] === "production") return;
try { try {
const target = new URL(url); const target = new URL(url);
const base = new URL(this.connectionService.getBaseUrl()); const base = new URL(this.connectionService.getBaseUrl());

View File

@ -207,7 +207,7 @@ export class WhmcsSsoService {
* Debug helper: log only the host of the SSO URL (never the token) in non-production. * Debug helper: log only the host of the SSO URL (never the token) in non-production.
*/ */
private debugLogRedirectHost(url: string): void { private debugLogRedirectHost(url: string): void {
if (process.env.NODE_ENV === "production") return; if (process.env["NODE_ENV"] === "production") return;
try { try {
const target = new URL(url); const target = new URL(url);
const base = new URL(this.connectionService.getBaseUrl()); const base = new URL(this.connectionService.getBaseUrl());

View File

@ -15,7 +15,6 @@ for (const signal of signals) {
if (!app) { if (!app) {
logger.warn("Nest application not initialized. Exiting immediately."); logger.warn("Nest application not initialized. Exiting immediately.");
process.exit(0); process.exit(0);
return;
} }
try { try {

View File

@ -109,15 +109,14 @@ export class AuthFacade {
const profile = mapPrismaUserToDomain(prismaUser); const profile = mapPrismaUserToDomain(prismaUser);
const userAgent = request?.headers?.["user-agent"];
const tokens = await this.tokenService.generateTokenPair( const tokens = await this.tokenService.generateTokenPair(
{ {
id: profile.id, id: profile.id,
email: profile.email, email: profile.email,
role: prismaUser.role || "USER", role: prismaUser.role || "USER",
}, },
{ userAgent ? { userAgent } : {}
userAgent: request?.headers["user-agent"],
}
); );
await this.updateAccountLastSignIn(user.id); await this.updateAccountLastSignIn(user.id);
@ -290,7 +289,7 @@ export class AuthFacade {
async refreshTokens( async refreshTokens(
refreshToken: string | undefined, refreshToken: string | undefined,
deviceInfo?: { deviceId?: string; userAgent?: string } deviceInfo?: { deviceId?: string | undefined; userAgent?: string | undefined }
) { ) {
if (!refreshToken) { if (!refreshToken) {
throw new UnauthorizedException("Invalid refresh token"); throw new UnauthorizedException("Invalid refresh token");

View File

@ -1,7 +1,6 @@
import type { User } from "@customer-portal/domain/customer"; import type { User, UserAuth } from "@customer-portal/domain/customer";
import type { Request } from "express"; import type { Request } from "express";
import type { AuthTokens } from "@customer-portal/domain/auth"; import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth } from "@customer-portal/domain/customer";
export type RequestWithUser = Request & { user: User }; export type RequestWithUser = Request & { user: User };

View File

@ -11,6 +11,7 @@ import type {
} from "@customer-portal/domain/get-started"; } from "@customer-portal/domain/get-started";
import { CacheService } from "@/infra/cache/cache.service.js"; import { CacheService } from "@/infra/cache/cache.service.js";
import { DistributedLockService } from "@/infra/cache/distributed-lock.service.js";
/** /**
* Session data stored in Redis (internal representation) * Session data stored in Redis (internal representation)
@ -18,6 +19,10 @@ import { CacheService } from "@/infra/cache/cache.service.js";
interface SessionData extends Omit<GetStartedSession, "expiresAt"> { interface SessionData extends Omit<GetStartedSession, "expiresAt"> {
/** Session ID (for lookup) */ /** Session ID (for lookup) */
id: string; id: string;
/** Timestamp when session was marked as used (for one-time operations) */
usedAt?: string;
/** The operation that used this session */
usedFor?: "guest_eligibility" | "signup_with_eligibility" | "complete_account";
} }
/** /**
@ -36,11 +41,13 @@ interface SessionData extends Omit<GetStartedSession, "expiresAt"> {
export class GetStartedSessionService { export class GetStartedSessionService {
private readonly SESSION_PREFIX = "get-started-session:"; private readonly SESSION_PREFIX = "get-started-session:";
private readonly HANDOFF_PREFIX = "guest-handoff:"; private readonly HANDOFF_PREFIX = "guest-handoff:";
private readonly SESSION_LOCK_PREFIX = "session-lock:";
private readonly ttlSeconds: number; private readonly ttlSeconds: number;
private readonly handoffTtlSeconds = 1800; // 30 minutes for handoff tokens private readonly handoffTtlSeconds = 1800; // 30 minutes for handoff tokens
constructor( constructor(
private readonly cache: CacheService, private readonly cache: CacheService,
private readonly lockService: DistributedLockService,
private readonly config: ConfigService, private readonly config: ConfigService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) { ) {
@ -277,6 +284,148 @@ export class GetStartedSessionService {
this.logger.debug({ tokenId: token }, "Guest handoff token invalidated"); this.logger.debug({ tokenId: token }, "Guest handoff token invalidated");
} }
// ============================================================================
// Session Locking (Idempotency Protection)
// ============================================================================
/**
* Atomically acquire a lock and mark the session as used for a specific operation.
*
* This prevents race conditions where the same session could be used
* multiple times (e.g., double-clicking "Create Account").
*
* @param sessionToken - Session token
* @param operation - The operation being performed
* @returns Object with success flag and session data if acquired
*/
async acquireAndMarkAsUsed(
sessionToken: string,
operation: SessionData["usedFor"]
): Promise<{ success: true; session: GetStartedSession } | { success: false; reason: string }> {
const lockKey = `${this.SESSION_LOCK_PREFIX}${sessionToken}`;
// Try to acquire lock with no retries (immediate fail if already locked)
const lockResult = await this.lockService.tryWithLock(
lockKey,
async () => {
// Check session state within lock
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
if (!sessionData) {
return { success: false as const, reason: "Session not found or expired" };
}
if (!sessionData.emailVerified) {
return { success: false as const, reason: "Session email not verified" };
}
if (sessionData.usedAt) {
this.logger.warn(
{ sessionId: sessionToken, usedFor: sessionData.usedFor, usedAt: sessionData.usedAt },
"Session already used"
);
return {
success: false as const,
reason: `Session already used for ${sessionData.usedFor}`,
};
}
// Mark as used - build object with required fields, then add optional fields
const updatedData = Object.assign(
{
id: sessionData.id,
email: sessionData.email,
emailVerified: sessionData.emailVerified,
createdAt: sessionData.createdAt,
usedAt: new Date().toISOString(),
usedFor: operation,
},
sessionData.accountStatus ? { accountStatus: sessionData.accountStatus } : {},
sessionData.firstName ? { firstName: sessionData.firstName } : {},
sessionData.lastName ? { lastName: sessionData.lastName } : {},
sessionData.phone ? { phone: sessionData.phone } : {},
sessionData.address ? { address: sessionData.address } : {},
sessionData.sfAccountId ? { sfAccountId: sessionData.sfAccountId } : {},
sessionData.whmcsClientId === undefined
? {}
: { whmcsClientId: sessionData.whmcsClientId },
sessionData.eligibilityStatus ? { eligibilityStatus: sessionData.eligibilityStatus } : {}
) as SessionData;
const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt);
await this.cache.set(this.buildKey(sessionToken), updatedData, remainingTtl);
this.logger.debug({ sessionId: sessionToken, operation }, "Session marked as used");
return {
success: true as const,
session: {
...updatedData,
expiresAt: this.calculateExpiresAt(sessionData.createdAt),
},
};
},
{ ttlMs: 65_000, maxRetries: 0 } // TTL must exceed workflow lock (60s) - fail fast
);
if (!lockResult.success) {
this.logger.warn(
{ sessionId: sessionToken },
"Failed to acquire session lock - operation in progress"
);
return { success: false, reason: "Operation already in progress" };
}
return lockResult.result;
}
/**
* Check if a session has already been used for an operation
*/
async isSessionUsed(sessionToken: string): Promise<boolean> {
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
return sessionData?.usedAt != null;
}
/**
* Clear the "used" status from a session (for recovery after partial failure)
*
* This should only be called when rolling back a failed operation
* to allow the user to retry.
*/
async clearUsedStatus(sessionToken: string): Promise<boolean> {
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
if (!sessionData) {
return false;
}
// Build clean session data without usedAt and usedFor
const cleanSessionData = Object.assign(
{
id: sessionData.id,
email: sessionData.email,
emailVerified: sessionData.emailVerified,
createdAt: sessionData.createdAt,
},
sessionData.accountStatus ? { accountStatus: sessionData.accountStatus } : {},
sessionData.firstName ? { firstName: sessionData.firstName } : {},
sessionData.lastName ? { lastName: sessionData.lastName } : {},
sessionData.phone ? { phone: sessionData.phone } : {},
sessionData.address ? { address: sessionData.address } : {},
sessionData.sfAccountId ? { sfAccountId: sessionData.sfAccountId } : {},
sessionData.whmcsClientId === undefined ? {} : { whmcsClientId: sessionData.whmcsClientId },
sessionData.eligibilityStatus ? { eligibilityStatus: sessionData.eligibilityStatus } : {}
) as SessionData;
const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt);
await this.cache.set(this.buildKey(sessionToken), cleanSessionData, remainingTtl);
this.logger.debug({ sessionId: sessionToken }, "Session used status cleared for retry");
return true;
}
private buildKey(sessionId: string): string { private buildKey(sessionId: string): string {
return `${this.SESSION_PREFIX}${sessionId}`; return `${this.SESSION_PREFIX}${sessionId}`;
} }

View File

@ -17,7 +17,7 @@ interface OtpData {
/** When the code was created */ /** When the code was created */
createdAt: string; createdAt: string;
/** Optional fingerprint binding (SHA256 hash of IP + User-Agent) */ /** Optional fingerprint binding (SHA256 hash of IP + User-Agent) */
fingerprint?: string; fingerprint?: string | undefined;
} }
/** /**

View File

@ -133,7 +133,6 @@ export class AuthRateLimitService {
points: limit, points: limit,
duration, duration,
inMemoryBlockOnConsumed: limit + 1, inMemoryBlockOnConsumed: limit + 1,
insuranceLimiter: undefined,
}); });
} catch (error: unknown) { } catch (error: unknown) {
this.logger.error( this.logger.error(

View File

@ -7,10 +7,10 @@ import { parseJwtExpiry } from "../../utils/jwt-expiry.util.js";
export class JoseJwtService { export class JoseJwtService {
private readonly signingKey: Uint8Array; private readonly signingKey: Uint8Array;
private readonly verificationKeys: Uint8Array[]; private readonly verificationKeys: Uint8Array[];
private readonly issuer?: string; private readonly issuer: string | undefined;
private readonly audience?: string | string[]; private readonly audience: string | string[] | undefined;
constructor(private readonly configService: ConfigService) { constructor(configService: ConfigService) {
const secret = configService.get<string>("JWT_SECRET"); const secret = configService.get<string>("JWT_SECRET");
if (!secret) { if (!secret) {
throw new Error("JWT_SECRET is required in environment variables"); throw new Error("JWT_SECRET is required in environment variables");
@ -83,15 +83,16 @@ export class JoseJwtService {
async verify<T extends JWTPayload>(token: string): Promise<T> { async verify<T extends JWTPayload>(token: string): Promise<T> {
const options = { const options = {
algorithms: ["HS256"], algorithms: ["HS256"] as string[],
issuer: this.issuer, ...(this.issuer === undefined ? {} : { issuer: this.issuer }),
audience: this.audience, ...(this.audience === undefined ? {} : { audience: this.audience }),
}; };
let lastError: unknown; let lastError: unknown;
for (let i = 0; i < this.verificationKeys.length; i++) { for (let i = 0; i < this.verificationKeys.length; i++) {
const key = this.verificationKeys[i]; const key = this.verificationKeys[i];
if (!key) continue;
try { try {
const { payload } = await jwtVerify(token, key, options); const { payload } = await jwtVerify(token, key, options);
return payload as T; return payload as T;

View File

@ -1,6 +1,5 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -32,8 +31,7 @@ export class TokenMigrationService {
constructor( constructor(
@Inject("REDIS_CLIENT") private readonly redis: Redis, @Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger
private readonly configService: ConfigService
) {} ) {}
/** /**
@ -415,7 +413,7 @@ export class TokenMigrationService {
} }
const record = parsed as Record<string, unknown>; const record = parsed as Record<string, unknown>;
const userId = record.userId; const userId = record["userId"];
if (typeof userId !== "string" || userId.length === 0) { if (typeof userId !== "string" || userId.length === 0) {
this.logger.warn("Invalid family structure, skipping", { familyKey }); this.logger.warn("Invalid family structure, skipping", { familyKey });
@ -448,8 +446,8 @@ export class TokenMigrationService {
} }
const record = parsed as Record<string, unknown>; const record = parsed as Record<string, unknown>;
const userId = record.userId; const userId = record["userId"];
const familyId = record.familyId; const familyId = record["familyId"];
if (typeof userId !== "string" || typeof familyId !== "string") { if (typeof userId !== "string" || typeof familyId !== "string") {
this.logger.warn("Invalid token structure, skipping", { tokenKey }); this.logger.warn("Invalid token structure, skipping", { tokenKey });

View File

@ -145,19 +145,22 @@ export class TokenRevocationService {
/** /**
* Get all active refresh token families for a user * Get all active refresh token families for a user
*/ */
async getUserRefreshTokenFamilies( async getUserRefreshTokenFamilies(userId: string): Promise<
userId: string Array<{
): Promise< familyId: string;
Array<{ familyId: string; deviceId?: string; userAgent?: string; createdAt?: string }> deviceId?: string | undefined;
userAgent?: string | undefined;
createdAt?: string | undefined;
}>
> { > {
try { try {
const familyIds = await this.storage.getUserFamilyIds(userId); const familyIds = await this.storage.getUserFamilyIds(userId);
const families: Array<{ const families: Array<{
familyId: string; familyId: string;
deviceId?: string; deviceId?: string | undefined;
userAgent?: string; userAgent?: string | undefined;
createdAt?: string; createdAt?: string | undefined;
}> = []; }> = [];
for (const familyId of familyIds) { for (const familyId of familyIds) {

View File

@ -11,10 +11,10 @@ export interface StoredRefreshToken {
export interface StoredRefreshTokenFamily { export interface StoredRefreshTokenFamily {
userId: string; userId: string;
tokenHash: string; tokenHash: string;
deviceId?: string; deviceId?: string | undefined;
userAgent?: string; userAgent?: string | undefined;
createdAt?: string; createdAt?: string | undefined;
absoluteExpiresAt?: string; absoluteExpiresAt?: string | undefined;
} }
/** /**
@ -41,7 +41,7 @@ export class TokenStorageService {
userId: string, userId: string,
familyId: string, familyId: string,
refreshTokenHash: string, refreshTokenHash: string,
deviceInfo?: { deviceId?: string; userAgent?: string }, deviceInfo?: { deviceId?: string | undefined; userAgent?: string | undefined },
refreshExpirySeconds?: number, refreshExpirySeconds?: number,
absoluteExpiresAt?: string absoluteExpiresAt?: string
): Promise<void> { ): Promise<void> {
@ -86,7 +86,7 @@ export class TokenStorageService {
const results = await pipeline.exec(); const results = await pipeline.exec();
// Check if user has too many tokens // Check if user has too many tokens
const cardResult = results?.[results.length - 1]; const cardResult = results?.at(-1);
if ( if (
cardResult && cardResult &&
Array.isArray(cardResult) && Array.isArray(cardResult) &&
@ -155,7 +155,7 @@ export class TokenStorageService {
familyId: string, familyId: string,
userId: string, userId: string,
newTokenHash: string, newTokenHash: string,
deviceInfo: { deviceId?: string; userAgent?: string } | undefined, deviceInfo: { deviceId?: string | undefined; userAgent?: string | undefined } | undefined,
createdAt: string, createdAt: string,
absoluteExpiresAt: string, absoluteExpiresAt: string,
ttlSeconds: number ttlSeconds: number

View File

@ -23,14 +23,14 @@ export interface RefreshTokenPayload extends JWTPayload {
* Refresh token family identifier (stable across rotations). * Refresh token family identifier (stable across rotations).
* Present on newly issued tokens; legacy tokens used `tokenId` for this value. * Present on newly issued tokens; legacy tokens used `tokenId` for this value.
*/ */
familyId?: string; familyId?: string | undefined;
/** /**
* Refresh token identifier (unique per token). Used for replay/reuse detection. * Refresh token identifier (unique per token). Used for replay/reuse detection.
* For legacy tokens, this was equal to the family id. * For legacy tokens, this was equal to the family id.
*/ */
tokenId: string; tokenId: string;
deviceId?: string; deviceId?: string | undefined;
userAgent?: string; userAgent?: string | undefined;
type: "refresh"; type: "refresh";
} }
@ -201,8 +201,8 @@ export class AuthTokenService {
async refreshTokens( async refreshTokens(
refreshToken: string, refreshToken: string,
deviceInfo?: { deviceInfo?: {
deviceId?: string; deviceId?: string | undefined;
userAgent?: string; userAgent?: string | undefined;
} }
): Promise<{ tokens: AuthTokens; user: User }> { ): Promise<{ tokens: AuthTokens; user: User }> {
if (!refreshToken) { if (!refreshToken) {
@ -297,10 +297,10 @@ export class AuthTokenService {
if (absoluteExpiresAt) { if (absoluteExpiresAt) {
const absMs = Date.parse(absoluteExpiresAt); const absMs = Date.parse(absoluteExpiresAt);
if (!Number.isNaN(absMs)) { if (Number.isNaN(absMs)) {
remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000));
} else {
absoluteExpiresAt = undefined; absoluteExpiresAt = undefined;
} else {
remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000));
} }
} }
@ -426,10 +426,13 @@ export class AuthTokenService {
/** /**
* Get all active refresh token families for a user * Get all active refresh token families for a user
*/ */
async getUserRefreshTokenFamilies( async getUserRefreshTokenFamilies(userId: string): Promise<
userId: string Array<{
): Promise< familyId: string;
Array<{ familyId: string; deviceId?: string; userAgent?: string; createdAt?: string }> deviceId?: string | undefined;
userAgent?: string | undefined;
createdAt?: string | undefined;
}>
> { > {
return this.revocation.getUserRefreshTokenFamilies(userId); return this.revocation.getUserRefreshTokenFamilies(userId);
} }
@ -451,7 +454,7 @@ export class AuthTokenService {
private parseExpiryToMs(expiry: string): number { private parseExpiryToMs(expiry: string): number {
const unit = expiry.slice(-1); const unit = expiry.slice(-1);
const value = parseInt(expiry.slice(0, -1)); const value = Number.parseInt(expiry.slice(0, -1));
switch (unit) { switch (unit) {
case "s": case "s":

View File

@ -3,6 +3,8 @@ import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import * as argon2 from "argon2"; import * as argon2 from "argon2";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
import { import {
ACCOUNT_STATUS, ACCOUNT_STATUS,
type AccountStatus, type AccountStatus,
@ -17,6 +19,7 @@ import {
type CompleteAccountRequest, type CompleteAccountRequest,
type MaybeLaterRequest, type MaybeLaterRequest,
type MaybeLaterResponse, type MaybeLaterResponse,
type SignupWithEligibilityRequest,
} from "@customer-portal/domain/get-started"; } from "@customer-portal/domain/get-started";
import { EmailService } from "@bff/infra/email/email.service.js"; import { EmailService } from "@bff/infra/email/email.service.js";
@ -43,6 +46,15 @@ import {
} from "@bff/modules/auth/constants/portal.constants.js"; } from "@bff/modules/auth/constants/portal.constants.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
/**
* Remove undefined properties from an object (for exactOptionalPropertyTypes compatibility)
*/
function removeUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([, value]) => value !== undefined)
) as Partial<T>;
}
/** /**
* Get Started Workflow Service * Get Started Workflow Service
* *
@ -70,6 +82,7 @@ export class GetStartedWorkflowService {
private readonly whmcsSignup: SignupWhmcsService, private readonly whmcsSignup: SignupWhmcsService,
private readonly userCreation: SignupUserCreationService, private readonly userCreation: SignupUserCreationService,
private readonly tokenService: AuthTokenService, private readonly tokenService: AuthTokenService,
private readonly lockService: DistributedLockService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -155,15 +168,23 @@ export class GetStartedWorkflowService {
const prefill = this.getPrefillData(normalizedEmail, accountStatus); const prefill = this.getPrefillData(normalizedEmail, accountStatus);
// Update session with verified status and account info // Update session with verified status and account info
await this.sessionService.markEmailVerified(sessionToken, accountStatus.status, { // Build prefill data object without undefined values (exactOptionalPropertyTypes)
firstName: prefill?.firstName, const prefillData = {
lastName: prefill?.lastName, ...(prefill?.firstName && { firstName: prefill.firstName }),
phone: prefill?.phone, ...(prefill?.lastName && { lastName: prefill.lastName }),
address: prefill?.address, ...(prefill?.phone && { phone: prefill.phone }),
sfAccountId: accountStatus.sfAccountId, ...(prefill?.address && { address: prefill.address }),
whmcsClientId: accountStatus.whmcsClientId, ...(accountStatus.sfAccountId && { sfAccountId: accountStatus.sfAccountId }),
eligibilityStatus: prefill?.eligibilityStatus, ...(accountStatus.whmcsClientId !== undefined && {
}); whmcsClientId: accountStatus.whmcsClientId,
}),
...(prefill?.eligibilityStatus && { eligibilityStatus: prefill.eligibilityStatus }),
};
await this.sessionService.markEmailVerified(
sessionToken,
accountStatus.status,
Object.keys(prefillData).length > 0 ? prefillData : undefined
);
this.logger.log( this.logger.log(
{ email: normalizedEmail, accountStatus: accountStatus.status }, { email: normalizedEmail, accountStatus: accountStatus.status },
@ -221,13 +242,13 @@ export class GetStartedWorkflowService {
// Create eligibility case // Create eligibility case
const requestId = await this.createEligibilityCase(sfAccountId, address); const requestId = await this.createEligibilityCase(sfAccountId, address);
// Update session with SF account info // Update session with SF account info (clean address to remove undefined values)
await this.sessionService.updateWithQuickCheckData(request.sessionToken, { await this.sessionService.updateWithQuickCheckData(request.sessionToken, {
firstName, firstName,
lastName, lastName,
address, address: removeUndefined(address),
phone, ...(phone && { phone }),
sfAccountId, ...(sfAccountId && { sfAccountId }),
}); });
return { return {
@ -286,6 +307,9 @@ export class GetStartedWorkflowService {
* Creates SF Account + eligibility case immediately. * Creates SF Account + eligibility case immediately.
* Email verification happens later when user creates an account. * Email verification happens later when user creates an account.
* *
* Security:
* - Email-level lock prevents concurrent requests creating duplicate SF accounts
*
* @param request - Guest eligibility request with name, email, address * @param request - Guest eligibility request with name, email, address
* @param fingerprint - Request fingerprint for logging/abuse detection * @param fingerprint - Request fingerprint for logging/abuse detection
*/ */
@ -301,65 +325,74 @@ export class GetStartedWorkflowService {
"Guest eligibility check initiated" "Guest eligibility check initiated"
); );
// Email-level lock to prevent concurrent requests for the same email
const lockKey = `guest-eligibility:${normalizedEmail}`;
try { try {
// Check if SF account already exists for this email return await this.lockService.withLock(
let sfAccountId: string; lockKey,
async () => {
// Check if SF account already exists for this email
let sfAccountId: string;
const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail); const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
if (existingSf) { if (existingSf) {
sfAccountId = existingSf.id; sfAccountId = existingSf.id;
this.logger.log( this.logger.log(
{ email: normalizedEmail, sfAccountId }, { email: normalizedEmail, sfAccountId },
"Using existing SF account for guest eligibility check" "Using existing SF account for guest eligibility check"
); );
} else { } else {
// Create new SF Account (email NOT verified) // Create new SF Account (email NOT verified)
const { accountId } = await this.salesforceAccountService.createAccount({ const { accountId } = await this.salesforceAccountService.createAccount({
firstName, firstName,
lastName, lastName,
email: normalizedEmail, email: normalizedEmail,
phone: phone ?? "", phone: phone ?? "",
}); });
sfAccountId = accountId; sfAccountId = accountId;
this.logger.log( this.logger.log(
{ email: normalizedEmail, sfAccountId }, { email: normalizedEmail, sfAccountId },
"Created SF account for guest eligibility check" "Created SF account for guest eligibility check"
); );
} }
// Create eligibility case // Create eligibility case
const requestId = await this.createEligibilityCase(sfAccountId, address); const requestId = await this.createEligibilityCase(sfAccountId, address);
// Update Account eligibility status to Pending // Update Account eligibility status to Pending
this.updateAccountEligibilityStatus(sfAccountId); this.updateAccountEligibilityStatus(sfAccountId);
// If user wants to continue to account creation, generate a handoff token // If user wants to continue to account creation, generate a handoff token
let handoffToken: string | undefined; let handoffToken: string | undefined;
if (continueToAccount) { if (continueToAccount) {
handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, { handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, {
firstName, firstName,
lastName, lastName,
address, address: removeUndefined(address),
phone, ...(phone && { phone }),
sfAccountId, sfAccountId,
}); });
this.logger.debug( this.logger.debug(
{ email: normalizedEmail, handoffToken }, { email: normalizedEmail, handoffToken },
"Created handoff token for account creation" "Created handoff token for account creation"
); );
} }
// Send confirmation email // Send confirmation email
await this.sendGuestEligibilityConfirmationEmail(normalizedEmail, firstName, requestId); await this.sendGuestEligibilityConfirmationEmail(normalizedEmail, firstName, requestId);
return { return {
submitted: true, submitted: true,
requestId, requestId,
sfAccountId, sfAccountId,
handoffToken, handoffToken,
message: "Eligibility check submitted. We'll notify you of the results.", message: "Eligibility check submitted. We'll notify you of the results.",
}; };
},
{ ttlMs: 30_000 } // 30 second lock timeout
);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
{ error: extractErrorMessage(error), email: normalizedEmail }, { error: extractErrorMessage(error), email: normalizedEmail },
@ -380,118 +413,334 @@ export class GetStartedWorkflowService {
/** /**
* Complete account for users with SF account but no WHMCS/Portal * Complete account for users with SF account but no WHMCS/Portal
* Creates WHMCS client and Portal user, links to existing SF account * Creates WHMCS client and Portal user, links to existing SF account
*
* Security:
* - Session is locked to prevent double submissions
* - Email-level lock prevents concurrent account creation
*/ */
async completeAccount(request: CompleteAccountRequest): Promise<AuthResultInternal> { async completeAccount(request: CompleteAccountRequest): Promise<AuthResultInternal> {
const session = await this.sessionService.validateVerifiedSession(request.sessionToken); // Atomically acquire session lock and mark as used
if (!session) { const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
throw new BadRequestException("Invalid or expired session. Please verify your email again."); request.sessionToken,
"complete_account"
);
if (!sessionResult.success) {
throw new BadRequestException(sessionResult.reason);
} }
const session = sessionResult.session;
if (!session.sfAccountId) { if (!session.sfAccountId) {
throw new BadRequestException("No Salesforce account found. Please check eligibility first."); throw new BadRequestException("No Salesforce account found. Please check eligibility first.");
} }
const { password, phone, dateOfBirth, gender } = request; const { password, phone, dateOfBirth, gender } = request;
const lockKey = `complete-account:${session.email}`;
// Verify SF account still exists
const existingSf = await this.salesforceAccountService.findByEmail(session.email);
if (!existingSf || existingSf.id !== session.sfAccountId) {
throw new BadRequestException("Account verification failed. Please start over.");
}
// Check for existing WHMCS client (shouldn't exist for SF-only flow)
const existingWhmcs = await this.whmcsDiscovery.findClientByEmail(session.email);
if (existingWhmcs) {
throw new ConflictException(
"A billing account already exists. Please use the account migration flow."
);
}
// Check for existing portal user
const existingPortalUser = await this.usersFacade.findByEmailInternal(session.email);
if (existingPortalUser) {
throw new ConflictException("An account already exists. Please log in.");
}
const passwordHash = await argon2.hash(password);
try { try {
// Get address from session or SF return await this.lockService.withLock(
const address = session.address; lockKey,
if (!address || !address.address1 || !address.city || !address.postcode) { async () => {
throw new BadRequestException("Address information is incomplete."); // Verify SF account still exists
} const existingSf = await this.salesforceAccountService.findByEmail(session.email);
if (!existingSf || existingSf.id !== session.sfAccountId) {
throw new BadRequestException("Account verification failed. Please start over.");
}
// Create WHMCS client // Check for existing WHMCS client (shouldn't exist for SF-only flow)
const whmcsClient = await this.whmcsSignup.createClient({ const existingWhmcs = await this.whmcsDiscovery.findClientByEmail(session.email);
firstName: session.firstName!, if (existingWhmcs) {
lastName: session.lastName!, throw new ConflictException(
email: session.email, "A billing account already exists. Please use the account migration flow."
password, );
phone, }
address: {
address1: address.address1, // Check for existing portal user
address2: address.address2 ?? undefined, const existingPortalUser = await this.usersFacade.findByEmailInternal(session.email);
city: address.city, if (existingPortalUser) {
state: address.state ?? "", throw new ConflictException("An account already exists. Please log in.");
postcode: address.postcode, }
country: address.country ?? "Japan",
const passwordHash = await argon2.hash(password);
// Get address from session or SF
const address = session.address;
if (!address || !address.address1 || !address.city || !address.postcode) {
throw new BadRequestException("Address information is incomplete.");
}
// Create WHMCS client
const whmcsClient = await this.whmcsSignup.createClient({
firstName: session.firstName!,
lastName: session.lastName!,
email: session.email,
password,
phone,
address: {
address1: address.address1,
...(address.address2 && { address2: address.address2 }),
city: address.city,
state: address.state ?? "",
postcode: address.postcode,
country: address.country ?? "Japan",
},
customerNumber: existingSf.accountNumber,
dateOfBirth,
gender,
});
// Create portal user and mapping
const { userId } = await this.userCreation.createUserWithMapping({
email: session.email,
passwordHash,
whmcsClientId: whmcsClient.clientId,
sfAccountId: session.sfAccountId,
});
// Fetch fresh user and generate tokens
const freshUser = await this.usersFacade.findByIdInternal(userId);
if (!freshUser) {
throw new Error("Failed to load created user");
}
await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, {
email: session.email,
whmcsClientId: whmcsClient.clientId,
source: "get_started_complete_account",
});
const profile = mapPrismaUserToDomain(freshUser);
const tokens = await this.tokenService.generateTokenPair({
id: profile.id,
email: profile.email,
});
// Update Salesforce portal flags
await this.updateSalesforcePortalFlags(session.sfAccountId, whmcsClient.clientId);
// Invalidate session (fully done)
await this.sessionService.invalidate(request.sessionToken);
this.logger.log(
{ email: session.email, userId },
"Account completed successfully for SF-only user"
);
return {
user: profile,
tokens,
};
}, },
customerNumber: existingSf.accountNumber, { ttlMs: 60_000 }
dateOfBirth,
gender,
});
// Create portal user and mapping
const { userId } = await this.userCreation.createUserWithMapping({
email: session.email,
passwordHash,
whmcsClientId: whmcsClient.clientId,
sfAccountId: session.sfAccountId,
});
// Fetch fresh user and generate tokens
const freshUser = await this.usersFacade.findByIdInternal(userId);
if (!freshUser) {
throw new Error("Failed to load created user");
}
await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, {
email: session.email,
whmcsClientId: whmcsClient.clientId,
source: "get_started_complete_account",
});
const profile = mapPrismaUserToDomain(freshUser);
const tokens = await this.tokenService.generateTokenPair({
id: profile.id,
email: profile.email,
});
// Update Salesforce portal flags
await this.updateSalesforcePortalFlags(session.sfAccountId, whmcsClient.clientId);
// Invalidate session
await this.sessionService.invalidate(request.sessionToken);
this.logger.log(
{ email: session.email, userId },
"Account completed successfully for SF-only user"
); );
return {
user: profile,
tokens,
};
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
{ error: extractErrorMessage(error), email: session.email }, { error: extractErrorMessage(error), email: session.email },
"Account completion failed" "Account completion failed"
); );
// Don't clear usedStatus on error - partial resources may have been created.
// The user must verify their email again to start fresh.
throw error; throw error;
} }
} }
// ============================================================================
// Full Signup with Eligibility (Inline Flow)
// ============================================================================
/**
* Full signup with eligibility check - creates everything in one operation
*
* This is the primary signup path from the eligibility check page.
* Creates SF Account + Case + WHMCS + Portal after email verification.
*
* Security:
* - Session is locked to prevent double submissions (race condition protection)
* - Email-level lock prevents concurrent signups for the same email
* - Session is invalidated on success, cleared on partial failure for retry
*
* @param request - Signup request with all required data
*/
async signupWithEligibility(request: SignupWithEligibilityRequest): Promise<{
success: boolean;
message?: string;
eligibilityRequestId?: string;
authResult?: AuthResultInternal;
}> {
// Atomically acquire session lock and mark as used
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
request.sessionToken,
"signup_with_eligibility"
);
if (!sessionResult.success) {
return {
success: false,
message: sessionResult.reason,
};
}
const session = sessionResult.session;
const { firstName, lastName, address, phone, password, dateOfBirth, gender } = request;
const normalizedEmail = session.email;
this.logger.log({ email: normalizedEmail }, "Starting signup with eligibility");
// Email-level lock to prevent concurrent signups for the same email
const lockKey = `signup-email:${normalizedEmail}`;
try {
return await this.lockService.withLock(
lockKey,
async () => {
// Check for existing Portal user
const existingPortalUser = await this.usersFacade.findByEmailInternal(normalizedEmail);
if (existingPortalUser) {
return {
success: false,
message: "An account already exists with this email. Please log in.",
};
}
// Check for existing WHMCS client
const existingWhmcs = await this.whmcsDiscovery.findClientByEmail(normalizedEmail);
if (existingWhmcs) {
return {
success: false,
message:
"A billing account already exists with this email. Please use account linking instead.",
};
}
// Check for existing SF Account or create new one
let sfAccountId: string;
let customerNumber: string | undefined;
const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
if (existingSf) {
sfAccountId = existingSf.id;
customerNumber = existingSf.accountNumber;
this.logger.log(
{ email: normalizedEmail, sfAccountId },
"Using existing SF account for signup"
);
} else {
// Create new SF Account
const { accountId, accountNumber } = await this.salesforceAccountService.createAccount({
firstName,
lastName,
email: normalizedEmail,
phone: phone ?? "",
});
sfAccountId = accountId;
customerNumber = accountNumber;
this.logger.log(
{ email: normalizedEmail, sfAccountId },
"Created new SF account for signup"
);
}
// Create eligibility case
const eligibilityRequestId = await this.createEligibilityCase(sfAccountId, address);
// Hash password
const passwordHash = await argon2.hash(password);
// Create WHMCS client
const whmcsClient = await this.whmcsSignup.createClient({
firstName,
lastName,
email: normalizedEmail,
password,
phone,
address: {
address1: address.address1,
...(address.address2 && { address2: address.address2 }),
city: address.city,
state: address.state ?? "",
postcode: address.postcode,
country: address.country ?? "Japan",
},
customerNumber,
dateOfBirth,
gender,
});
// Create Portal user and mapping
const { userId } = await this.userCreation.createUserWithMapping({
email: normalizedEmail,
passwordHash,
whmcsClientId: whmcsClient.clientId,
sfAccountId,
});
// Fetch fresh user and generate tokens
const freshUser = await this.usersFacade.findByIdInternal(userId);
if (!freshUser) {
throw new Error("Failed to load created user");
}
await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, {
email: normalizedEmail,
whmcsClientId: whmcsClient.clientId,
source: "signup_with_eligibility",
});
const profile = mapPrismaUserToDomain(freshUser);
const tokens = await this.tokenService.generateTokenPair({
id: profile.id,
email: profile.email,
});
// Update Salesforce portal flags
await this.updateSalesforcePortalFlags(sfAccountId, whmcsClient.clientId);
// Invalidate session (fully done, no retry needed)
await this.sessionService.invalidate(request.sessionToken);
// Send welcome email (includes eligibility info)
await this.sendWelcomeWithEligibilityEmail(
normalizedEmail,
firstName,
eligibilityRequestId
);
this.logger.log(
{ email: normalizedEmail, userId, eligibilityRequestId },
"Signup with eligibility completed successfully"
);
return {
success: true,
eligibilityRequestId,
authResult: {
user: profile,
tokens,
},
};
},
{ ttlMs: 60_000 } // 60 second lock timeout for the full operation
);
} catch (error) {
this.logger.error(
{ error: extractErrorMessage(error), email: normalizedEmail },
"Signup with eligibility failed"
);
// Don't clear usedStatus on error - partial resources may have been created.
// The user must verify their email again to start fresh.
// This prevents potential duplicate resource creation on retry.
return {
success: false,
message: "Account creation failed. Please verify your email again to retry.",
};
}
}
// ============================================================================ // ============================================================================
// Private Helpers // Private Helpers
// ============================================================================ // ============================================================================
@ -593,6 +842,41 @@ export class GetStartedWorkflowService {
} }
} }
private async sendWelcomeWithEligibilityEmail(
email: string,
firstName: string,
eligibilityRequestId: string
): Promise<void> {
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
const templateId = this.config.get<string>("EMAIL_TEMPLATE_WELCOME_WITH_ELIGIBILITY");
if (templateId) {
await this.emailService.sendEmail({
to: email,
subject: "Welcome! Your account is ready",
templateId,
dynamicTemplateData: {
firstName,
portalUrl: appBase,
dashboardUrl: `${appBase}/account/dashboard`,
eligibilityRequestId,
},
});
} else {
await this.emailService.sendEmail({
to: email,
subject: "Welcome! Your account is ready",
html: `
<p>Hi ${firstName},</p>
<p>Welcome! Your account has been created successfully.</p>
<p>We're also checking internet availability at your address. We'll notify you of the results within 1-2 business days.</p>
<p>Reference ID: ${eligibilityRequestId}</p>
<p>Log in to your dashboard: <a href="${appBase}/account/dashboard">${appBase}/account/dashboard</a></p>
`,
});
}
}
private async determineAccountStatus( private async determineAccountStatus(
email: string email: string
): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> { ): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> {

View File

@ -134,20 +134,20 @@ export class SignupWorkflowService {
lastName, lastName,
email, email,
password, password,
company: company ?? undefined, ...(company ? { company } : {}),
phone: phone, phone: phone,
address: { address: {
address1: address!.address1!, address1: address!.address1!,
address2: address?.address2 ?? undefined, ...(address?.address2 ? { address2: address.address2 } : {}),
city: address!.city!, city: address!.city!,
state: address!.state!, state: address!.state!,
postcode: address!.postcode!, postcode: address!.postcode!,
country: address!.country!, country: address!.country!,
}, },
customerNumber: customerNumberForWhmcs, customerNumber: customerNumberForWhmcs,
dateOfBirth, ...(dateOfBirth ? { dateOfBirth } : {}),
gender, ...(gender ? { gender } : {}),
nationality, ...(nationality ? { nationality } : {}),
}); });
// Step 5: Create user and mapping in database // Step 5: Create user and mapping in database
@ -228,7 +228,7 @@ export class SignupWorkflowService {
status: PORTAL_STATUS_ACTIVE, status: PORTAL_STATUS_ACTIVE,
source, source,
lastSignedInAt: new Date(), lastSignedInAt: new Date(),
whmcsAccountId, ...(whmcsAccountId === undefined ? {} : { whmcsAccountId }),
}); });
} catch (error) { } catch (error) {
this.logger.warn("Failed to update Salesforce portal flags after signup", { this.logger.warn("Failed to update Salesforce portal flags after signup", {

View File

@ -7,8 +7,8 @@
*/ */
export interface SignupAccountSnapshot { export interface SignupAccountSnapshot {
id: string; id: string;
Name?: string | null; Name?: string | null | undefined;
WH_Account__c?: string | null; WH_Account__c?: string | null | undefined;
} }
/** /**
@ -38,14 +38,14 @@ export interface SignupPreflightResult {
}; };
portal: { portal: {
userExists: boolean; userExists: boolean;
needsPasswordSet?: boolean; needsPasswordSet?: boolean | undefined;
}; };
salesforce: { salesforce: {
accountId?: string; accountId?: string | undefined;
alreadyMapped: boolean; alreadyMapped: boolean;
}; };
whmcs: { whmcs: {
clientExists: boolean; clientExists: boolean;
clientId?: number; clientId?: number | undefined;
}; };
} }

View File

@ -215,12 +215,12 @@ export class AuthController {
@Req() req: RequestWithCookies, @Req() req: RequestWithCookies,
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
) { ) {
const refreshToken = body.refreshToken ?? req.cookies?.refresh_token; const refreshToken = body.refreshToken ?? req.cookies?.["refresh_token"];
const rawUserAgent = req.headers["user-agent"]; const rawUserAgent = req.headers["user-agent"];
const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined; const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined;
const result = await this.authFacade.refreshTokens(refreshToken, { const result = await this.authFacade.refreshTokens(refreshToken, {
deviceId: body.deviceId, deviceId: body.deviceId ?? undefined,
userAgent, userAgent: userAgent ?? undefined,
}); });
this.setAuthCookies(res, result.tokens); this.setAuthCookies(res, result.tokens);
return { user: result.user, session: this.toSession(result.tokens) }; return { user: result.user, session: this.toSession(result.tokens) };

View File

@ -18,6 +18,8 @@ import {
completeAccountRequestSchema, completeAccountRequestSchema,
maybeLaterRequestSchema, maybeLaterRequestSchema,
maybeLaterResponseSchema, maybeLaterResponseSchema,
signupWithEligibilityRequestSchema,
signupWithEligibilityResponseSchema,
} from "@customer-portal/domain/get-started"; } from "@customer-portal/domain/get-started";
import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js"; import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js";
@ -34,6 +36,8 @@ class GuestEligibilityResponseDto extends createZodDto(guestEligibilityResponseS
class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {} class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {}
class MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {} class MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {}
class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {} class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {}
class SignupWithEligibilityRequestDto extends createZodDto(signupWithEligibilityRequestSchema) {}
class SignupWithEligibilityResponseDto extends createZodDto(signupWithEligibilityResponseSchema) {}
const ACCESS_COOKIE_PATH = "/api"; const ACCESS_COOKIE_PATH = "/api";
const REFRESH_COOKIE_PATH = "/api/auth/refresh"; const REFRESH_COOKIE_PATH = "/api/auth/refresh";
@ -177,7 +181,7 @@ export class GetStartedController {
res.cookie("access_token", result.tokens.accessToken, { res.cookie("access_token", result.tokens.accessToken, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env["NODE_ENV"] === "production",
sameSite: "lax", sameSite: "lax",
path: ACCESS_COOKIE_PATH, path: ACCESS_COOKIE_PATH,
maxAge: calculateCookieMaxAge(accessExpires), maxAge: calculateCookieMaxAge(accessExpires),
@ -185,7 +189,7 @@ export class GetStartedController {
res.cookie("refresh_token", result.tokens.refreshToken, { res.cookie("refresh_token", result.tokens.refreshToken, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env["NODE_ENV"] === "production",
sameSite: "lax", sameSite: "lax",
path: REFRESH_COOKIE_PATH, path: REFRESH_COOKIE_PATH,
maxAge: calculateCookieMaxAge(refreshExpires), maxAge: calculateCookieMaxAge(refreshExpires),
@ -200,4 +204,65 @@ export class GetStartedController {
}, },
}; };
} }
/**
* Full signup with eligibility check (inline flow)
* Creates SF Account + Case + WHMCS + Portal in one operation
*
* Used when user clicks "Create Account" on the eligibility check page.
* This is the primary signup path - creates all accounts at once after OTP verification.
*
* Returns auth tokens (sets httpOnly cookies)
*
* Rate limit: 5 per 15 minutes per IP
*/
@Public()
@Post("signup-with-eligibility")
@HttpCode(200)
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 900 })
async signupWithEligibility(
@Body() body: SignupWithEligibilityRequestDto,
@Res({ passthrough: true }) res: Response
): Promise<SignupWithEligibilityResponseDto | { user: unknown; session: unknown }> {
const result = await this.workflow.signupWithEligibility(body);
if (!result.success || !result.authResult) {
return {
success: false,
message: result.message,
};
}
// Set auth cookies (same pattern as complete-account)
const accessExpires = result.authResult.tokens.expiresAt;
const refreshExpires = result.authResult.tokens.refreshExpiresAt;
res.cookie("access_token", result.authResult.tokens.accessToken, {
httpOnly: true,
secure: process.env["NODE_ENV"] === "production",
sameSite: "lax",
path: ACCESS_COOKIE_PATH,
maxAge: calculateCookieMaxAge(accessExpires),
});
res.cookie("refresh_token", result.authResult.tokens.refreshToken, {
httpOnly: true,
secure: process.env["NODE_ENV"] === "production",
sameSite: "lax",
path: REFRESH_COOKIE_PATH,
maxAge: calculateCookieMaxAge(refreshExpires),
});
return {
success: true,
eligibilityRequestId: result.eligibilityRequestId,
user: result.authResult.user,
session: {
expiresAt: accessExpires,
refreshExpiresAt: refreshExpires,
tokenType: TOKEN_TYPE,
},
};
}
} }

View File

@ -11,8 +11,8 @@ export class LocalAuthGuard implements CanActivate {
const request = context.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<Request>();
const body = (request.body ?? {}) as Record<string, unknown>; const body = (request.body ?? {}) as Record<string, unknown>;
const email = typeof body.email === "string" ? body.email : ""; const email = typeof body["email"] === "string" ? body["email"] : "";
const password = typeof body.password === "string" ? body.password : ""; const password = typeof body["password"] === "string" ? body["password"] : "";
if (!email || !password) { if (!email || !password) {
throw new UnauthorizedException("Invalid credentials"); throw new UnauthorizedException("Invalid credentials");

View File

@ -23,7 +23,7 @@ export const parseJwtExpiry = (expiresIn: string | number | undefined | null): n
const unit = trimmed.slice(-1); const unit = trimmed.slice(-1);
const valuePortion = trimmed.slice(0, -1); const valuePortion = trimmed.slice(0, -1);
const parsedValue = parseInt(valuePortion, 10); const parsedValue = Number.parseInt(valuePortion, 10);
const toSeconds = (multiplier: number) => { const toSeconds = (multiplier: number) => {
if (Number.isNaN(parsedValue) || parsedValue <= 0) { if (Number.isNaN(parsedValue) || parsedValue <= 0) {

View File

@ -24,7 +24,7 @@ const pickFirstStringHeader = (value: unknown): string | undefined => {
}; };
export const resolveAuthorizationHeader = (headers: RequestHeadersLike): string | undefined => { export const resolveAuthorizationHeader = (headers: RequestHeadersLike): string | undefined => {
const raw = headers?.authorization; const raw = headers?.["authorization"];
return pickFirstStringHeader(raw); return pickFirstStringHeader(raw);
}; };
@ -41,6 +41,6 @@ export const extractAccessTokenFromRequest = (request: RequestWithCookies): stri
const headerToken = extractBearerToken(request.headers); const headerToken = extractBearerToken(request.headers);
if (headerToken) return headerToken; if (headerToken) return headerToken;
const cookieToken = request.cookies?.access_token; const cookieToken = request.cookies?.["access_token"];
return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined; return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined;
}; };

View File

@ -45,7 +45,7 @@ export class InvoiceRetrievalService {
const invoiceList = await this.whmcsInvoiceService.getInvoices(whmcsClientId, userId, { const invoiceList = await this.whmcsInvoiceService.getInvoices(whmcsClientId, userId, {
page, page,
limit, limit,
status, ...(status !== undefined && { status }),
}); });
this.logger.log(`Retrieved ${invoiceList.invoices.length} invoices for user ${userId}`, { this.logger.log(`Retrieved ${invoiceList.invoices.length} invoices for user ${userId}`, {
@ -100,7 +100,7 @@ export class InvoiceRetrievalService {
const queryStatus = status as "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; const queryStatus = status as "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
return withErrorHandling( return withErrorHandling(
() => this.getInvoices(userId, { page, limit, status: queryStatus }), async () => this.getInvoices(userId, { page, limit, status: queryStatus }),
this.logger, this.logger,
{ {
context: `Get ${status} invoices for user ${userId}`, context: `Get ${status} invoices for user ${userId}`,

View File

@ -20,10 +20,10 @@ export class HealthController {
// Database check // Database check
try { try {
await this.prisma.$queryRaw`SELECT 1`; await this.prisma.$queryRaw`SELECT 1`;
checks.database = "ok"; checks["database"] = "ok";
} catch (error) { } catch (error) {
this.logger.error({ error }, "Database health check failed"); this.logger.error({ error }, "Database health check failed");
checks.database = "fail"; checks["database"] = "fail";
} }
// Cache check // Cache check
@ -31,11 +31,11 @@ export class HealthController {
const key = "health:check"; const key = "health:check";
await this.cache.set(key, { ok: true }, 5); await this.cache.set(key, { ok: true }, 5);
const value = await this.cache.get<{ ok: boolean }>(key); const value = await this.cache.get<{ ok: boolean }>(key);
checks.cache = value?.ok ? "ok" : "fail"; checks["cache"] = value?.ok ? "ok" : "fail";
await this.cache.del(key); await this.cache.del(key);
} catch (error) { } catch (error) {
this.logger.error({ error }, "Cache health check failed"); this.logger.error({ error }, "Cache health check failed");
checks.cache = "fail"; checks["cache"] = "fail";
} }
const status = Object.values(checks).every(v => v === "ok") ? "ok" : "degraded"; const status = Object.values(checks).every(v => v === "ok") ? "ok" : "degraded";

View File

@ -57,7 +57,7 @@ export class MappingCacheService {
if (mapping.sfAccountId) { if (mapping.sfAccountId) {
keys.push(this.buildCacheKey("sfAccountId", mapping.sfAccountId)); keys.push(this.buildCacheKey("sfAccountId", mapping.sfAccountId));
} }
await Promise.all(keys.map(key => this.cacheService.del(key))); await Promise.all(keys.map(async key => this.cacheService.del(key)));
this.logger.debug(`Deleted mapping cache for user ${mapping.userId}`); this.logger.debug(`Deleted mapping cache for user ${mapping.userId}`);
} }

View File

@ -10,7 +10,7 @@ export interface UserIdMapping {
id: string; id: string;
userId: string; userId: string;
whmcsClientId: number; whmcsClientId: number;
sfAccountId?: string | null; sfAccountId?: string | null | undefined;
createdAt: IsoDateTimeString | Date; createdAt: IsoDateTimeString | Date;
updatedAt: IsoDateTimeString | Date; updatedAt: IsoDateTimeString | Date;
} }
@ -18,12 +18,12 @@ export interface UserIdMapping {
export interface CreateMappingRequest { export interface CreateMappingRequest {
userId: string; userId: string;
whmcsClientId: number; whmcsClientId: number;
sfAccountId?: string; sfAccountId?: string | undefined;
} }
export interface UpdateMappingRequest { export interface UpdateMappingRequest {
whmcsClientId?: number; whmcsClientId?: number | undefined;
sfAccountId?: string; sfAccountId?: string | undefined;
} }
/** /**

View File

@ -98,10 +98,11 @@ export function validateDeletion(
* The schema handles validation; this is purely for data cleanup. * The schema handles validation; this is purely for data cleanup.
*/ */
export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest { export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
const trimmedSfAccountId = request.sfAccountId?.trim();
return { return {
userId: request.userId?.trim(), userId: request.userId?.trim(),
whmcsClientId: request.whmcsClientId, whmcsClientId: request.whmcsClientId,
sfAccountId: request.sfAccountId?.trim() || undefined, ...(trimmedSfAccountId ? { sfAccountId: trimmedSfAccountId } : {}),
}; };
} }
@ -112,14 +113,15 @@ export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMapp
* The schema handles validation; this is purely for data cleanup. * The schema handles validation; this is purely for data cleanup.
*/ */
export function sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest { export function sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest {
const sanitized: Partial<UpdateMappingRequest> = {}; const sanitized: UpdateMappingRequest = {};
if (request.whmcsClientId !== undefined) { if (request.whmcsClientId !== undefined) {
sanitized.whmcsClientId = request.whmcsClientId; sanitized.whmcsClientId = request.whmcsClientId;
} }
if (request.sfAccountId !== undefined) { const trimmedSfAccountId = request.sfAccountId?.trim();
sanitized.sfAccountId = request.sfAccountId?.trim() || undefined; if (trimmedSfAccountId) {
sanitized.sfAccountId = trimmedSfAccountId;
} }
return sanitized; return sanitized;

View File

@ -80,7 +80,13 @@ export class MappingsService {
let created; let created;
try { try {
created = await this.prisma.idMapping.create({ data: sanitizedRequest }); // Convert undefined to null for Prisma compatibility
const prismaData = {
userId: sanitizedRequest.userId,
whmcsClientId: sanitizedRequest.whmcsClientId,
sfAccountId: sanitizedRequest.sfAccountId ?? null,
};
created = await this.prisma.idMapping.create({ data: prismaData });
} catch (e) { } catch (e) {
const msg = extractErrorMessage(e); const msg = extractErrorMessage(e);
if (msg.includes("P2002") || /unique/i.test(msg)) { if (msg.includes("P2002") || /unique/i.test(msg)) {
@ -245,9 +251,18 @@ export class MappingsService {
} }
} }
// Convert undefined to null for Prisma compatibility
const prismaUpdateData: Prisma.IdMappingUpdateInput = {
...(sanitizedUpdates.whmcsClientId !== undefined && {
whmcsClientId: sanitizedUpdates.whmcsClientId,
}),
...(sanitizedUpdates.sfAccountId !== undefined && {
sfAccountId: sanitizedUpdates.sfAccountId ?? null,
}),
};
const updated = await this.prisma.idMapping.update({ const updated = await this.prisma.idMapping.update({
where: { userId }, where: { userId },
data: sanitizedUpdates, data: prismaUpdateData,
}); });
const newMapping = mapPrismaMappingToDomain(updated); const newMapping = mapPrismaMappingToDomain(updated);

View File

@ -186,32 +186,32 @@ export class MeStatusService {
// Priority 3: pending orders // Priority 3: pending orders
if (orders && orders.length > 0) { if (orders && orders.length > 0) {
const pendingOrders = orders.filter( const pendingOrder = orders.find(
o => o =>
o.status === "Draft" || o.status === "Draft" ||
o.status === "Pending" || o.status === "Pending" ||
(o.status === "Activated" && o.activationStatus !== "Completed") (o.status === "Activated" && o.activationStatus !== "Completed")
); );
if (pendingOrders.length > 0) { const firstPendingOrder = pendingOrder;
const order = pendingOrders[0]; if (firstPendingOrder) {
const statusText = const statusText =
order.status === "Pending" firstPendingOrder.status === "Pending"
? "awaiting review" ? "awaiting review"
: order.status === "Draft" : firstPendingOrder.status === "Draft"
? "in draft" ? "in draft"
: "being activated"; : "being activated";
tasks.push({ tasks.push({
id: `order-${order.id}`, id: `order-${firstPendingOrder.id}`,
priority: 3, priority: 3,
type: "order", type: "order",
title: "Order in progress", title: "Order in progress",
description: `${order.orderType || "Your"} order is ${statusText}`, description: `${firstPendingOrder.orderType || "Your"} order is ${statusText}`,
actionLabel: "View details", actionLabel: "View details",
detailHref: `/account/orders/${order.id}`, detailHref: `/account/orders/${firstPendingOrder.id}`,
tone: "info", tone: "info",
metadata: { orderId: order.id }, metadata: { orderId: firstPendingOrder.id },
}); });
} }
} }

View File

@ -36,12 +36,12 @@ const NOTIFICATION_DEDUPE_WINDOW_HOURS: Partial<Record<NotificationTypeValue, nu
export interface CreateNotificationParams { export interface CreateNotificationParams {
userId: string; userId: string;
type: NotificationTypeValue; type: NotificationTypeValue;
title?: string; title?: string | undefined;
message?: string; message?: string | undefined;
actionUrl?: string; actionUrl?: string | undefined;
actionLabel?: string; actionLabel?: string | undefined;
source?: NotificationSourceValue; source?: NotificationSourceValue | undefined;
sourceId?: string; sourceId?: string | undefined;
} }
@Injectable() @Injectable()

View File

@ -109,13 +109,13 @@ export class OrdersController {
req.user?.id req.user?.id
); );
const uniqueSkus = Array.from( const uniqueSkus = [
new Set( ...new Set(
cart.items cart.items
.map(item => item.sku) .map(item => item.sku)
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0) .filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
) ),
); ];
if (uniqueSkus.length === 0) { if (uniqueSkus.length === 0) {
throw new NotFoundException("Checkout session contains no items"); throw new NotFoundException("Checkout session contains no items");

View File

@ -47,6 +47,6 @@ export class ProvisioningQueueService {
async depth(): Promise<number> { async depth(): Promise<number> {
const counts = await this.queue.getJobCounts("waiting", "active", "delayed"); const counts = await this.queue.getJobCounts("waiting", "active", "delayed");
return (counts.waiting || 0) + (counts.active || 0) + (counts.delayed || 0); return (counts["waiting"] || 0) + (counts["active"] || 0) + (counts["delayed"] || 0);
} }
} }

View File

@ -448,7 +448,11 @@ export class CheckoutService {
return { fee: defaultFee, autoAdded: true }; return { fee: defaultFee, autoAdded: true };
} }
return { fee: activationFees[0], autoAdded: true }; const firstFee = activationFees[0];
if (!firstFee) {
return null;
}
return { fee: firstFee, autoAdded: true };
} }
/** /**
@ -460,14 +464,14 @@ export class CheckoutService {
// Handle various addon selection formats // Handle various addon selection formats
if (selections.addonSku) refs.add(selections.addonSku); if (selections.addonSku) refs.add(selections.addonSku);
if (selections.addons) { if (selections.addons) {
selections.addons for (const value of selections.addons
.split(",") .split(",")
.map(value => value.trim()) .map(value => value.trim())
.filter(Boolean) .filter(Boolean))
.forEach(value => refs.add(value)); refs.add(value);
} }
return Array.from(refs); return [...refs];
} }
/** /**

View File

@ -124,7 +124,7 @@ export class OrderBuilder {
try { try {
const profile = await this.usersFacade.getProfile(userId); const profile = await this.usersFacade.getProfile(userId);
const address = profile.address; const address = profile.address;
const orderAddress = (body.configurations as Record<string, unknown>)?.address as const orderAddress = (body.configurations as Record<string, unknown>)?.["address"] as
| Record<string, unknown> | Record<string, unknown>
| undefined; | undefined;
const addressChanged = !!orderAddress; const addressChanged = !!orderAddress;

View File

@ -43,7 +43,7 @@ export class OrderFulfillmentErrorService {
* Get user-friendly error message for external consumption * Get user-friendly error message for external consumption
* Ensures no sensitive information is exposed * Ensures no sensitive information is exposed
*/ */
getUserFriendlyMessage(error: unknown, errorCode: OrderFulfillmentErrorCode): string { getUserFriendlyMessage(_error: unknown, errorCode: OrderFulfillmentErrorCode): string {
switch (errorCode) { switch (errorCode) {
case ORDER_FULFILLMENT_ERROR_CODE.PAYMENT_METHOD_MISSING: case ORDER_FULFILLMENT_ERROR_CODE.PAYMENT_METHOD_MISSING:
return "Payment method missing - please add a payment method before fulfillment"; return "Payment method missing - please add a payment method before fulfillment";

View File

@ -31,18 +31,18 @@ type WhmcsOrderItemMappingResult = ReturnType<typeof mapOrderToWhmcsItems>;
export interface OrderFulfillmentStep { export interface OrderFulfillmentStep {
step: string; step: string;
status: "pending" | "in_progress" | "completed" | "failed"; status: "pending" | "in_progress" | "completed" | "failed";
startedAt?: Date; startedAt?: Date | undefined;
completedAt?: Date; completedAt?: Date | undefined;
error?: string; error?: string | undefined;
} }
export interface OrderFulfillmentContext { export interface OrderFulfillmentContext {
sfOrderId: string; sfOrderId: string;
idempotencyKey: string; idempotencyKey: string;
validation: OrderFulfillmentValidationResult | null; validation: OrderFulfillmentValidationResult | null;
orderDetails?: OrderDetails; orderDetails?: OrderDetails | undefined;
mappingResult?: WhmcsOrderItemMappingResult; mappingResult?: WhmcsOrderItemMappingResult | undefined;
whmcsResult?: WhmcsOrderResult; whmcsResult?: WhmcsOrderResult | undefined;
steps: OrderFulfillmentStep[]; steps: OrderFulfillmentStep[];
} }
@ -92,7 +92,7 @@ export class OrderFulfillmentOrchestrator {
idempotencyKey, idempotencyKey,
validation: null, validation: null,
steps: this.initializeSteps( steps: this.initializeSteps(
typeof payload.orderType === "string" ? payload.orderType : "Unknown" typeof payload["orderType"] === "string" ? payload["orderType"] : "Unknown"
), ),
}; };
@ -105,6 +105,7 @@ export class OrderFulfillmentOrchestrator {
// Step 1: Validation (no rollback needed) // Step 1: Validation (no rollback needed)
this.updateStepStatus(context, "validation", "in_progress"); this.updateStepStatus(context, "validation", "in_progress");
try { try {
// eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions
context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest(
sfOrderId, sfOrderId,
idempotencyKey idempotencyKey
@ -146,6 +147,7 @@ export class OrderFulfillmentOrchestrator {
idempotencyKey, idempotencyKey,
}); });
} }
// eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions
context.orderDetails = orderDetails; context.orderDetails = orderDetails;
} catch (error) { } catch (error) {
this.logger.error("Failed to get order details", { this.logger.error("Failed to get order details", {
@ -197,7 +199,7 @@ export class OrderFulfillmentOrchestrator {
{ {
id: "order_details", id: "order_details",
description: "Retain order details in context", description: "Retain order details in context",
execute: this.createTrackedStep(context, "order_details", () => execute: this.createTrackedStep(context, "order_details", async () =>
Promise.resolve(context.orderDetails) Promise.resolve(context.orderDetails)
), ),
critical: false, critical: false,
@ -205,7 +207,7 @@ export class OrderFulfillmentOrchestrator {
{ {
id: "mapping", id: "mapping",
description: "Map OrderItems to WHMCS format", description: "Map OrderItems to WHMCS format",
execute: this.createTrackedStep(context, "mapping", () => { execute: this.createTrackedStep(context, "mapping", async () => {
if (!context.orderDetails) { if (!context.orderDetails) {
return Promise.reject(new Error("Order details are required for mapping")); return Promise.reject(new Error("Order details are required for mapping"));
} }
@ -263,7 +265,7 @@ export class OrderFulfillmentOrchestrator {
whmcsCreateResult = result; whmcsCreateResult = result;
return result; return result;
}), }),
rollback: () => { rollback: async () => {
if (whmcsCreateResult?.orderId) { if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API // Note: WHMCS doesn't have an automated cancel API
// Manual intervention required for order cleanup // Manual intervention required for order cleanup
@ -297,7 +299,7 @@ export class OrderFulfillmentOrchestrator {
await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId); await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId);
return { orderId: whmcsCreateResult.orderId }; return { orderId: whmcsCreateResult.orderId };
}), }),
rollback: () => { rollback: async () => {
if (whmcsCreateResult?.orderId) { if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API for accepted orders // Note: WHMCS doesn't have an automated cancel API for accepted orders
// Manual intervention required for service termination // Manual intervention required for service termination
@ -320,7 +322,7 @@ export class OrderFulfillmentOrchestrator {
description: "SIM-specific fulfillment (if applicable)", description: "SIM-specific fulfillment (if applicable)",
execute: this.createTrackedStep(context, "sim_fulfillment", async () => { execute: this.createTrackedStep(context, "sim_fulfillment", async () => {
if (context.orderDetails?.orderType === "SIM") { if (context.orderDetails?.orderType === "SIM") {
const configurations = this.extractConfigurations(payload.configurations); const configurations = this.extractConfigurations(payload["configurations"]);
await this.simFulfillmentService.fulfillSimOrder({ await this.simFulfillmentService.fulfillSimOrder({
orderDetails: context.orderDetails, orderDetails: context.orderDetails,
configurations, configurations,
@ -444,7 +446,9 @@ export class OrderFulfillmentOrchestrator {
} }
// Update context with results // Update context with results
// eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions
context.mappingResult = mappingResult; context.mappingResult = mappingResult;
// eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions
context.whmcsResult = whmcsCreateResult; context.whmcsResult = whmcsCreateResult;
this.logger.log("Transactional fulfillment completed successfully", { this.logger.log("Transactional fulfillment completed successfully", {
@ -584,8 +588,8 @@ export class OrderFulfillmentOrchestrator {
this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode) this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode)
) )
.toString() .toString()
.substring(0, 60), .slice(0, 60),
Activation_Error_Message__c: userMessage?.substring(0, 255), Activation_Error_Message__c: userMessage?.slice(0, 255),
}; };
await this.salesforceService.updateOrder(updates as { Id: string; [key: string]: unknown }); await this.salesforceService.updateOrder(updates as { Id: string; [key: string]: unknown });
@ -608,8 +612,8 @@ export class OrderFulfillmentOrchestrator {
getFulfillmentSummary(context: OrderFulfillmentContext): { getFulfillmentSummary(context: OrderFulfillmentContext): {
success: boolean; success: boolean;
status: "Already Fulfilled" | "Fulfilled" | "Failed"; status: "Already Fulfilled" | "Fulfilled" | "Failed";
whmcsOrderId?: string; whmcsOrderId?: string | undefined;
whmcsServiceIds?: number[]; whmcsServiceIds?: number[] | undefined;
message: string; message: string;
steps: OrderFulfillmentStep[]; steps: OrderFulfillmentStep[];
} { } {
@ -617,21 +621,24 @@ export class OrderFulfillmentOrchestrator {
const failedStep = context.steps.find((s: OrderFulfillmentStep) => s.status === "failed"); const failedStep = context.steps.find((s: OrderFulfillmentStep) => s.status === "failed");
if (context.validation?.isAlreadyProvisioned) { if (context.validation?.isAlreadyProvisioned) {
const whmcsOrderId = context.validation.whmcsOrderId;
return { return {
success: true, success: true,
status: "Already Fulfilled", status: "Already Fulfilled",
whmcsOrderId: context.validation.whmcsOrderId, ...(whmcsOrderId !== undefined && { whmcsOrderId }),
message: "Order was already fulfilled in WHMCS", message: "Order was already fulfilled in WHMCS",
steps: context.steps, steps: context.steps,
}; };
} }
if (isSuccess) { if (isSuccess) {
const whmcsOrderId = context.whmcsResult?.orderId.toString();
const whmcsServiceIds = context.whmcsResult?.serviceIds;
return { return {
success: true, success: true,
status: "Fulfilled", status: "Fulfilled",
whmcsOrderId: context.whmcsResult?.orderId.toString(), ...(whmcsOrderId !== undefined && { whmcsOrderId }),
whmcsServiceIds: context.whmcsResult?.serviceIds, ...(whmcsServiceIds !== undefined && { whmcsServiceIds }),
message: "Order fulfilled successfully in WHMCS", message: "Order fulfilled successfully in WHMCS",
steps: context.steps, steps: context.steps,
}; };
@ -640,7 +647,7 @@ export class OrderFulfillmentOrchestrator {
return { return {
success: false, success: false,
status: "Failed", status: "Failed",
message: failedStep?.error || "Fulfillment failed", message: failedStep?.error ?? "Fulfillment failed",
steps: context.steps, steps: context.steps,
}; };
} }
@ -658,13 +665,17 @@ export class OrderFulfillmentOrchestrator {
if (status === "in_progress") { if (status === "in_progress") {
step.status = "in_progress"; step.status = "in_progress";
step.startedAt = timestamp; step.startedAt = timestamp;
step.error = undefined; delete step.error;
return; return;
} }
step.status = status; step.status = status;
step.completedAt = timestamp; step.completedAt = timestamp;
step.error = status === "failed" ? error : undefined; if (status === "failed" && error !== undefined) {
step.error = error;
} else {
delete step.error;
}
} }
private createTrackedStep<TResult>( private createTrackedStep<TResult>(

View File

@ -4,9 +4,11 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { sfOrderIdParamSchema } from "@customer-portal/domain/orders"; import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers"; import type {
OrderFulfillmentValidationResult,
SalesforceOrderRecord,
} from "@customer-portal/domain/orders/providers";
import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
import { PaymentValidatorService } from "./payment-validator.service.js"; import { PaymentValidatorService } from "./payment-validator.service.js";
/** /**

Some files were not shown because too many files have changed in this diff Show More