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
**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:
- `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
**Always ask first** before:

View File

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

View File

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

View File

@ -7,6 +7,10 @@ import helmet from "helmet";
import cookieParser from "cookie-parser";
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 */
declare global {
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> {
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 { 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
const bffRoot = process.cwd();

View File

@ -12,17 +12,17 @@ export interface DevAuthConfig {
}
export const createDevAuthConfig = (): DevAuthConfig => {
const isDevelopment = process.env.NODE_ENV !== "production";
const isDevelopment = process.env["NODE_ENV"] !== "production";
return {
// 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
disableRateLimit: isDevelopment && process.env.DISABLE_RATE_LIMIT === "true",
disableRateLimit: isDevelopment && process.env["DISABLE_RATE_LIMIT"] === "true",
// 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
enableDebugLogs: isDevelopment,

View File

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

View File

@ -1,86 +1,88 @@
import { Global, Module } from "@nestjs/common";
import { LoggerModule } from "nestjs-pino";
import type { Options as PinoHttpOptions } from "pino-http";
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()
@Module({
imports: [
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: () => ({}),
},
},
}),
],
imports: [LoggerModule.forRoot({ pinoHttp: pinoHttpConfig })],
exports: [LoggerModule],
})
export class LoggingModule {}

View File

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

View File

@ -7,13 +7,13 @@ export interface CsrfTokenData {
token: string;
secret: string;
expiresAt: Date;
sessionId?: string;
userId?: string;
sessionId?: string | undefined;
userId?: string | undefined;
}
export interface CsrfValidationResult {
isValid: boolean;
reason?: string;
reason?: string | undefined;
}
export interface CsrfTokenStats {
@ -191,7 +191,7 @@ export class CsrfService {
}
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 {

View File

@ -11,7 +11,7 @@ export function isErrorWithMessage(error: unknown): error is Error {
typeof error === "object" &&
error !== null &&
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
*/
interface EnhancedError extends Error {
code?: string;
statusCode?: number;
code?: string | undefined;
statusCode?: number | undefined;
cause?: unknown;
}

View File

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

View File

@ -22,16 +22,28 @@ export enum AuditAction {
}
export interface AuditLogData {
userId?: string;
userId?: string | undefined;
action: AuditAction;
resource?: string;
details?: Record<string, unknown> | string | number | boolean | null;
ipAddress?: string;
userAgent?: string;
success?: boolean;
error?: string;
resource?: string | undefined;
details?: Record<string, unknown> | string | number | boolean | null | undefined;
ipAddress?: string | undefined;
userAgent?: string | undefined;
success?: boolean | undefined;
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()
export class AuditService {
constructor(
@ -41,23 +53,24 @@ export class AuditService {
async log(data: AuditLogData): Promise<void> {
try {
await this.prisma.auditLog.create({
data: {
userId: data.userId,
action: data.action,
resource: data.resource,
details:
data.details === undefined
? undefined
: data.details === null
? Prisma.JsonNull
: (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue),
ipAddress: data.ipAddress,
userAgent: data.userAgent,
success: data.success ?? true,
error: data.error,
},
});
const createData: Parameters<typeof this.prisma.auditLog.create>[0]["data"] = {
action: data.action,
success: data.success ?? true,
};
if (data.userId !== undefined) createData.userId = data.userId;
if (data.resource !== undefined) createData.resource = data.resource;
if (data.ipAddress !== undefined) createData.ipAddress = data.ipAddress;
if (data.userAgent !== undefined) createData.userAgent = data.userAgent;
if (data.error !== undefined) createData.error = data.error;
if (data.details !== undefined) {
createData.details =
data.details === null
? Prisma.JsonNull
: (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue);
}
await this.prisma.auditLog.create({ data: createData });
} catch (error) {
this.logger.error("Audit logging failed", {
errorType: error instanceof Error ? error.constructor.name : "Unknown",
@ -70,12 +83,7 @@ export class AuditService {
action: AuditAction,
userId?: string,
details?: Record<string, unknown> | string | number | boolean | null,
request?: {
headers?: Record<string, string | string[] | undefined>;
ip?: string;
connection?: { remoteAddress?: string };
socket?: { remoteAddress?: string };
},
request?: AuditRequest,
success: boolean = true,
error?: string
): Promise<void> {

View File

@ -78,22 +78,23 @@ export class CacheService {
*/
async delPattern(pattern: string): Promise<void> {
const pipeline = this.redis.pipeline();
let pending = 0;
const state = { pending: 0 };
const flush = async () => {
if (pending === 0) {
if (state.pending === 0) {
return;
}
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 => {
keys.forEach(key => {
for (const key of keys) {
pipeline.del(key);
pending += 1;
});
if (pending >= 1000) {
state.pending += 1;
}
if (state.pending >= 1000) {
await flush();
}
});
@ -122,9 +123,9 @@ export class CacheService {
let total = 0;
await this.scanPattern(pattern, async keys => {
const pipeline = this.redis.pipeline();
keys.forEach(key => {
for (const key of keys) {
pipeline.memory("USAGE", key);
});
}
const results = await pipeline.exec();
if (!results) {
return;

View File

@ -65,7 +65,7 @@ export class DistributedLockService {
return {
key: lockKey,
token,
release: () => this.release(lockKey, token),
release: async () => this.release(lockKey, token),
};
}
@ -183,7 +183,9 @@ export class DistributedLockService {
/**
* Delay helper
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
private async delay(ms: number): Promise<void> {
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 {
private readonly logger = new Logger(PrismaService.name);
private readonly pool: Pool;
private readonly instanceTag: string;
private destroyCalls = 0;
private poolEnded = false;
constructor() {
const connectionString = process.env.DATABASE_URL;
const connectionString = process.env["DATABASE_URL"];
if (!connectionString) {
throw new Error("DATABASE_URL environment variable is required");
}
@ -43,7 +42,6 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
super({ adapter });
this.pool = pool;
this.instanceTag = `${process.pid}:${Date.now()}`;
}
async onModuleInit() {

View File

@ -24,8 +24,8 @@ export interface DistributedTransactionResult<
TStepResults extends StepResultMap = StepResultMap,
> {
success: boolean;
data?: TData;
error?: string;
data?: TData | undefined;
error?: string | undefined;
duration: number;
stepsExecuted: number;
stepsRolledBack: number;
@ -254,7 +254,7 @@ export class DistributedTransactionService {
databaseOperation,
{
description: `${options.description} - Database Operations`,
timeout: options.timeout,
...(options.timeout !== undefined && { timeout: options.timeout }),
}
);
@ -305,7 +305,7 @@ export class DistributedTransactionService {
databaseOperation,
{
description: `${options.description} - Database Operations`,
timeout: options.timeout,
...(options.timeout !== undefined && { timeout: options.timeout }),
}
);
@ -442,6 +442,6 @@ export class DistributedTransactionService {
}
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)
* Default: 30 seconds
*/
timeout?: number;
timeout?: number | undefined;
/**
* Maximum number of retry attempts on serialization failures
* Default: 3
*/
maxRetries?: number;
maxRetries?: number | undefined;
/**
* Custom isolation level for the transaction
* Default: ReadCommitted
*/
isolationLevel?: "ReadUncommitted" | "ReadCommitted" | "RepeatableRead" | "Serializable";
isolationLevel?:
| "ReadUncommitted"
| "ReadCommitted"
| "RepeatableRead"
| "Serializable"
| undefined;
/**
* Description of the transaction for logging
*/
description?: string;
description?: string | undefined;
/**
* Whether to automatically rollback external operations on database rollback
* Default: true
*/
autoRollback?: boolean;
autoRollback?: boolean | undefined;
}
export interface TransactionResult<T> {
success: boolean;
data?: T;
error?: string;
data?: T | undefined;
error?: string | undefined;
duration: number;
operationsCount: number;
rollbacksExecuted: number;
@ -289,8 +294,10 @@ export class TransactionService {
// Execute rollbacks in reverse order (LIFO)
for (let i = context.rollbackActions.length - 1; i >= 0; i--) {
const rollbackAction = context.rollbackActions[i];
if (!rollbackAction) continue;
try {
await context.rollbackActions[i]();
await rollbackAction();
rollbacksExecuted++;
this.logger.debug(`Rollback ${i + 1} completed [${context.id}]`);
} catch (rollbackError) {
@ -321,11 +328,13 @@ export class TransactionService {
}
private async delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
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 {
message: string;
field?: string;
help?: string;
field?: string | undefined;
help?: string | undefined;
}
export interface ParsedSendGridError {
@ -215,8 +215,8 @@ export class SendGridEmailProvider implements OnModuleInit {
private maskEmail(email: string | string[]): string | string[] {
const mask = (e: string): string => {
const [local, domain] = e.split("@");
if (!domain) return "***";
const maskedLocal = local.length > 2 ? `${local[0]}***${local[local.length - 1]}` : "***";
if (!domain || !local) return "***";
const maskedLocal = local.length > 2 ? `${local[0]}***${local.at(-1)}` : "***";
return `${maskedLocal}@${domain}`;
};

View File

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

View File

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

View File

@ -8,16 +8,16 @@ export interface SalesforceRouteMetricsInternal {
label: string;
totalRequests: number;
failedRequests: number;
lastSuccessTime?: Date;
lastErrorTime?: Date;
lastSuccessTime?: Date | undefined;
lastErrorTime?: Date | undefined;
}
export interface SalesforceRouteMetricsSnapshot {
totalRequests: number;
failedRequests: number;
successRate: number;
lastSuccessTime?: Date;
lastErrorTime?: Date;
lastSuccessTime?: Date | undefined;
lastErrorTime?: Date | undefined;
}
export interface SalesforceQueueMetricsData {
@ -29,9 +29,9 @@ export interface SalesforceQueueMetricsData {
averageWaitTime: number;
averageExecutionTime: number;
dailyApiUsage: number;
lastRequestTime?: Date;
lastErrorTime?: Date;
lastRateLimitTime?: Date;
lastRequestTime?: Date | undefined;
lastErrorTime?: Date | undefined;
lastRateLimitTime?: Date | undefined;
}
@Injectable()
@ -52,7 +52,10 @@ export class SalesforceQueueMetricsService {
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

View File

@ -22,13 +22,13 @@ export interface SalesforceQueueMetrics {
averageWaitTime: number;
averageExecutionTime: number;
dailyApiUsage: number;
lastRequestTime?: Date;
lastErrorTime?: Date;
lastRateLimitTime?: Date;
dailyApiLimit?: number;
dailyUsagePercent?: number;
routeBreakdown?: Record<string, SalesforceRouteMetricsSnapshot>;
degradation?: SalesforceDegradationSnapshot;
lastRequestTime?: Date | undefined;
lastErrorTime?: Date | undefined;
lastRateLimitTime?: Date | undefined;
dailyApiLimit?: number | undefined;
dailyUsagePercent?: number | undefined;
routeBreakdown?: Record<string, SalesforceRouteMetricsSnapshot> | undefined;
degradation?: SalesforceDegradationSnapshot | undefined;
}
export interface SalesforceRequestOptions {
@ -480,7 +480,9 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
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 {
priority?: number; // Higher number = higher priority (0-10)
timeout?: number; // Request timeout in ms
retryAttempts?: number; // Number of retry attempts
retryDelay?: number; // Base delay between retries in ms
priority?: number | undefined; // Higher number = higher priority (0-10)
timeout?: number | undefined; // Request timeout in ms
retryAttempts?: number | undefined; // Number of retry attempts
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));
}

View File

@ -109,7 +109,7 @@ export class RealtimeService {
}
const evt = this.buildMessage(message.event, message.data);
set.forEach(observer => {
for (const observer of set) {
try {
observer.next(evt);
} catch (error) {
@ -118,7 +118,7 @@ export class RealtimeService {
error: extractErrorMessage(error),
});
}
});
}
}
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 configured = config.detailsEndpoint || "/master/getAcnt/";
const candidates = Array.from(
new Set([
const candidates = [
...new Set([
configured,
configured.replace(/\/$/, ""),
"/master/getAcnt/",
@ -53,8 +53,8 @@ export class FreebitAccountService {
"/mvno/getInfo",
"/master/getDetail/",
"/master/getDetail",
])
);
]),
];
let response: FreebitAccountDetailsResponse | undefined;
let lastError: unknown;

View File

@ -80,9 +80,9 @@ export class FreebitAuthService {
}
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 =
data?.status?.statusCode != null ? String(data.status.statusCode).trim() : undefined;
data?.status?.statusCode == null ? undefined : String(data.status.statusCode).trim();
if (resultCode !== "100") {
throw new FreebitError(

View File

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

View File

@ -57,7 +57,7 @@ export class FreebitClientService {
});
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);
this.logger.error("Freebit API HTTP error", {
url,
@ -78,7 +78,7 @@ export class FreebitClientService {
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
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", {
url,
resultCode,
@ -166,7 +166,7 @@ export class FreebitClientService {
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
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", {
url,
resultCode,

View File

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

View File

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

View File

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

View File

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

View File

@ -37,7 +37,7 @@ export class FreebitPlanService {
account: string,
newPlanCode: string,
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
): Promise<{ ipv4?: string; ipv6?: string }> {
): Promise<{ ipv4?: string | undefined; ipv6?: string | undefined }> {
try {
return await this.rateLimiter.executeWithSpacing(account, "plan", async () => {
// First, get current SIM details to log for debugging
@ -127,17 +127,17 @@ export class FreebitPlanService {
};
if (error instanceof Error) {
errorDetails.errorName = error.name;
errorDetails.errorMessage = error.message;
errorDetails["errorName"] = error.name;
errorDetails["errorMessage"] = error.message;
if ("resultCode" in error) {
errorDetails.resultCode = error.resultCode;
errorDetails["resultCode"] = (error as Record<string, unknown>)["resultCode"];
}
if ("statusCode" in error) {
errorDetails.statusCode = error.statusCode;
errorDetails["statusCode"] = (error as Record<string, unknown>)["statusCode"];
}
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 = {};
for (const [field, value] of Object.entries(raw)) {
const num = Number(value);
if (!isNaN(num)) {
if (!Number.isNaN(num)) {
parsed[field as keyof OperationTimestamps] = num;
}
}

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import {
type SalesforceOrderFieldMap,
} 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> = {
order: "ORDER",

View File

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

View File

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

View File

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

View File

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

View File

@ -45,7 +45,7 @@ export class SalesforceService implements OnModuleInit {
}
} catch (error) {
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";
if (isProd) {
this.logger.error("Failed to initialize Salesforce connection");
@ -63,15 +63,19 @@ export class SalesforceService implements OnModuleInit {
return this.accountService.findByCustomerNumber(customerNumber);
}
async findAccountWithDetailsByCustomerNumber(
customerNumber: string
): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> {
async findAccountWithDetailsByCustomerNumber(customerNumber: string): Promise<{
id: string;
WH_Account__c?: string | null | undefined;
Name?: string | null | undefined;
} | null> {
return this.accountService.findWithDetailsByCustomerNumber(customerNumber);
}
async getAccountDetails(
accountId: string
): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> {
async getAccountDetails(accountId: string): Promise<{
id: string;
WH_Account__c?: string | null | undefined;
Name?: string | null | undefined;
} | null> {
return this.accountService.getAccountDetails(accountId);
}

View File

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

View File

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

View File

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

View File

@ -40,7 +40,7 @@ export class SalesforceAccountService {
private readonly portalSourceField: string;
private readonly portalLastSignedInField: 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)
@ -65,8 +65,8 @@ export class SalesforceAccountService {
async findWithDetailsByCustomerNumber(customerNumber: string): Promise<{
id: string;
Name?: string | null;
WH_Account__c?: string | null;
Name?: string | null | undefined;
WH_Account__c?: string | null | undefined;
} | null> {
const validCustomerNumber = customerNumberSchema.parse(customerNumber);
@ -98,9 +98,11 @@ export class SalesforceAccountService {
* Get account details including WH_Account__c field
* Used in signup workflow to check if account is already linked to WHMCS
*/
async getAccountDetails(
accountId: string
): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> {
async getAccountDetails(accountId: string): Promise<{
id: string;
WH_Account__c?: string | null | undefined;
Name?: string | null | undefined;
} | null> {
const validAccountId = salesforceIdSchema.parse(accountId);
try {
@ -196,9 +198,9 @@ export class SalesforceAccountService {
this.logger.debug("Person Account creation payload", {
recordTypeId: personAccountRecordTypeId,
hasFirstName: Boolean(accountPayload.FirstName),
hasLastName: Boolean(accountPayload.LastName),
hasPersonEmail: Boolean(accountPayload.PersonEmail),
hasFirstName: Boolean(accountPayload["FirstName"]),
hasLastName: Boolean(accountPayload["LastName"]),
hasPersonEmail: Boolean(accountPayload["PersonEmail"]),
});
try {
@ -249,18 +251,18 @@ export class SalesforceAccountService {
if (error && typeof error === "object") {
const err = error as Record<string, unknown>;
// jsforce errors often have these properties
if (err.errorCode) errorDetails.errorCode = err.errorCode;
if (err.fields) errorDetails.fields = err.fields;
if (err.message) errorDetails.rawMessage = err.message;
if (err["errorCode"]) errorDetails["errorCode"] = err["errorCode"];
if (err["fields"]) errorDetails["fields"] = err["fields"];
if (err["message"]) errorDetails["rawMessage"] = err["message"];
// 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
if (process.env.NODE_ENV !== "production") {
errorDetails.allProperties = Object.keys(err);
if (process.env["NODE_ENV"] !== "production") {
errorDetails["allProperties"] = Object.keys(err);
try {
errorDetails.fullError = JSON.stringify(error, null, 2);
errorDetails["fullError"] = JSON.stringify(error, null, 2);
} 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", {
recordTypeId,
name: recordTypeQuery.records[0].Name,
name: record.Name,
});
return recordTypeId;
} 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 type { SalesforceResponse } from "@customer-portal/domain/common/providers";
import type { SupportCase, CaseMessageList } from "@customer-portal/domain/support";
import type {
SalesforceCaseRecord,
SalesforceEmailMessage,
SalesforceCaseComment,
} from "@customer-portal/domain/support/providers";
import {
type SalesforceCaseRecord,
type SalesforceEmailMessage,
type SalesforceCaseComment,
type SalesforceCaseOrigin,
SALESFORCE_CASE_ORIGIN,
SALESFORCE_CASE_STATUS,
SALESFORCE_CASE_PRIORITY,
toSalesforcePriority,
type SalesforceCaseOrigin,
} from "@customer-portal/domain/support/providers";
import {
buildCaseByIdQuery,
buildCaseSelectFields,
buildCasesForAccountQuery,
@ -65,11 +61,11 @@ export interface CreateCaseParams {
/** Case origin - determines visibility and routing */
origin: SalesforceCaseOrigin;
/** Priority (defaults to Medium) */
priority?: string;
priority?: string | undefined;
/** Optional Salesforce Contact ID */
contactId?: string;
contactId?: string | undefined;
/** Optional Opportunity ID for workflow cases */
opportunityId?: string;
opportunityId?: string | undefined;
}
/**
@ -80,9 +76,9 @@ export interface CreateWebCaseParams {
description: string;
suppliedEmail: string;
suppliedName: string;
suppliedPhone?: string;
origin?: string;
priority?: string;
suppliedPhone?: string | undefined;
origin?: string | undefined;
priority?: string | undefined;
}
@Injectable()

View File

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

View File

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

View File

@ -16,7 +16,7 @@ export interface SftpConfig {
* 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.
*/
hostKeySha256?: string;
hostKeySha256?: string | undefined;
}
@Injectable()

View File

@ -248,14 +248,14 @@ export class WhmcsCacheService {
async invalidateUserCache(userId: string): Promise<void> {
try {
const patterns = [
`${this.cacheConfigs.invoices.prefix}:${userId}:*`,
`${this.cacheConfigs.invoice.prefix}:${userId}:*`,
`${this.cacheConfigs.subscriptions.prefix}:${userId}:*`,
`${this.cacheConfigs.subscription.prefix}:${userId}:*`,
`${this.cacheConfigs.subscriptionInvoicesAll.prefix}:${userId}:*`,
`${this.cacheConfigs["invoices"]?.prefix}:${userId}:*`,
`${this.cacheConfigs["invoice"]?.prefix}:${userId}:*`,
`${this.cacheConfigs["subscriptions"]?.prefix}:${userId}:*`,
`${this.cacheConfigs["subscription"]?.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}`);
} catch (error) {
@ -292,7 +292,7 @@ export class WhmcsCacheService {
.filter(config => config.tags.includes(tag))
.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}`);
} catch (error) {
@ -308,8 +308,8 @@ export class WhmcsCacheService {
async invalidateInvoice(userId: string, invoiceId: number): Promise<void> {
try {
const specificKey = this.buildInvoiceKey(userId, invoiceId);
const listPattern = `${this.cacheConfigs.invoices.prefix}:${userId}:*`;
const subscriptionInvoicesPattern = `${this.cacheConfigs.subscriptionInvoicesAll.prefix}:${userId}:*`;
const listPattern = `${this.cacheConfigs["invoices"]?.prefix}:${userId}:*`;
const subscriptionInvoicesPattern = `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`;
await Promise.all([
this.cacheService.del(specificKey),
@ -432,6 +432,10 @@ export class WhmcsCacheService {
private async set<T>(key: string, data: T, configKey: string): Promise<void> {
try {
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);
this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`);
} catch (error) {
@ -443,28 +447,28 @@ export class WhmcsCacheService {
* Build cache key for invoices list
*/
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
*/
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
*/
private buildSubscriptionsKey(userId: string): string {
return `${this.cacheConfigs.subscriptions.prefix}:${userId}`;
return `${this.cacheConfigs["subscriptions"]?.prefix}:${userId}`;
}
/**
* Build cache key for individual subscription
*/
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,
limit: number
): 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
*/
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
*/
private buildClientKey(clientId: number): string {
return `${this.cacheConfigs.client.prefix}:${clientId}`;
return `${this.cacheConfigs["client"]?.prefix}:${clientId}`;
}
/**
* Build cache key for client email mapping
*/
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
*/
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> {
try {
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");
} catch (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);
if (!value) return defaultValue;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? defaultValue : parsed;
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
}

View File

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

View File

@ -128,7 +128,7 @@ export class WhmcsHttpClientService {
if (!response.ok) {
// 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.
if (process.env.NODE_ENV !== "production") {
if (process.env["NODE_ENV"] !== "production") {
const snippet = responseText?.slice(0, 300);
if (snippet) {
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 {
if (Array.isArray(value)) {
value.forEach((entry, index) => {
for (const [index, entry] of value.entries()) {
this.appendFormParam(formData, `${key}[${index}]`, entry);
});
}
return;
}
@ -234,11 +234,11 @@ export class WhmcsHttpClientService {
try {
parsedResponse = JSON.parse(responseText);
} 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}]`, {
...(isProd
? { responseTextLength: responseText.length }
: { responseText: responseText.substring(0, 500) }),
: { responseText: responseText.slice(0, 500) }),
parseError: extractErrorMessage(parseError),
params: redactForLogs(params),
});
@ -247,12 +247,12 @@ export class WhmcsHttpClientService {
// Validate basic response structure
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}]`, {
responseType: typeof parsedResponse,
...(isProd
? { responseTextLength: responseText.length }
: { responseText: responseText.substring(0, 500) }),
: { responseText: responseText.slice(0, 500) }),
params: redactForLogs(params),
});
throw new Error("Invalid response structure from WHMCS API");
@ -308,7 +308,7 @@ export class WhmcsHttpClientService {
}
const record = value as Record<string, unknown>;
const rawResult = record.result;
const rawResult = record["result"];
return rawResult === "success" || rawResult === "error";
}

View File

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

View File

@ -114,13 +114,14 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
const response = (await this.connectionService.getCurrencies()) as WhmcsCurrenciesResponse;
// 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
this.currencies = this.parseWhmcsCurrenciesResponse(response);
if (this.currencies.length > 0) {
// 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`, {
defaultCurrency: this.defaultCurrency?.code,
@ -134,13 +135,13 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
} else {
this.logger.error("WHMCS GetCurrencies returned error", {
result: response?.result,
message: response?.message,
error: response?.error,
errorcode: response?.errorcode,
message: response?.["message"],
error: response?.["error"],
errorcode: response?.["errorcode"],
fullResponse: JSON.stringify(response, null, 2),
});
throw new WhmcsOperationException(
`WHMCS GetCurrencies error: ${response?.message || response?.error || "Unknown error"}`,
`WHMCS GetCurrencies error: ${response?.["message"] || response?.["error"] || "Unknown error"}`,
{ operation: "getCurrencies" }
);
}
@ -171,7 +172,7 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
for (const currencyData of currencyArray) {
const currency: Currency = {
id: parseInt(String(currencyData.id)) || 0,
id: Number.parseInt(String(currencyData.id)) || 0,
code: String(currencyData.code || ""),
prefix: String(currencyData.prefix || ""),
suffix: String(currencyData.suffix || ""),
@ -194,14 +195,15 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
const currencyIndices = currencyKeys
.map(key => {
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);
// Build currency objects from the flat response
for (const index of currencyIndices) {
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]`] || ""),
prefix: String(response[`currencies[currency][${index}][prefix]`] || ""),
suffix: String(response[`currencies[currency][${index}][suffix]`] || ""),

View File

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

View File

@ -10,8 +10,8 @@ import type {
WhmcsAddOrderResponse,
WhmcsOrderResult,
} from "@customer-portal/domain/orders/providers";
import { buildWhmcsAddOrderPayload } from "@customer-portal/domain/orders/providers";
import {
buildWhmcsAddOrderPayload,
whmcsAddOrderResponseSchema,
whmcsAcceptOrderResponseSchema,
} from "@customer-portal/domain/orders/providers";
@ -47,14 +47,16 @@ export class WhmcsOrderService {
this.logger.debug("Built WHMCS AddOrder payload", {
clientId: params.clientId,
productCount: Array.isArray(addOrderPayload.pid) ? addOrderPayload.pid.length : 0,
pids: addOrderPayload.pid,
quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added
billingCycles: addOrderPayload.billingcycle,
hasConfigOptions: Boolean(addOrderPayload.configoptions),
hasCustomFields: Boolean(addOrderPayload.customfields),
promoCode: addOrderPayload.promocode,
paymentMethod: addOrderPayload.paymentmethod,
productCount: Array.isArray(addOrderPayload["pid"])
? (addOrderPayload["pid"] as unknown[]).length
: 0,
pids: addOrderPayload["pid"],
quantities: addOrderPayload["qty"], // CRITICAL: Must be included for products to be added
billingCycles: addOrderPayload["billingcycle"],
hasConfigOptions: Boolean(addOrderPayload["configoptions"]),
hasCustomFields: Boolean(addOrderPayload["customfields"]),
promoCode: addOrderPayload["promocode"],
paymentMethod: addOrderPayload["paymentmethod"],
});
// Call WHMCS AddOrder API
@ -104,7 +106,7 @@ export class WhmcsOrderService {
sfOrderId: params.sfOrderId,
itemCount: params.items.length,
// 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;
}
@ -163,7 +165,7 @@ export class WhmcsOrderService {
orderId,
sfOrderId,
// 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;
}
@ -179,7 +181,8 @@ export class WhmcsOrderService {
id: orderId.toString(),
})) 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) {
this.logger.error("Failed to get WHMCS order details", {
error: extractErrorMessage(error),
@ -231,17 +234,17 @@ export class WhmcsOrderService {
this.logger.debug("Built WHMCS AddOrder payload", {
clientId: params.clientId,
productCount: params.items.length,
pids: payload.pid,
billingCycles: payload.billingcycle,
hasConfigOptions: !!payload.configoptions,
hasCustomFields: !!payload.customfields,
pids: payload["pid"],
billingCycles: payload["billingcycle"],
hasConfigOptions: !!payload["configoptions"],
hasCustomFields: !!payload["customfields"],
});
return payload as Record<string, unknown>;
}
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)) {
throw new WhmcsOperationException("WHMCS AddOrder did not return valid order ID", {
response,
@ -250,7 +253,7 @@ export class WhmcsOrderService {
return {
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),
addonIds: this.parseDelimitedIds(response.addonids),
domainIds: this.parseDelimitedIds(response.domainids),
@ -264,7 +267,7 @@ export class WhmcsOrderService {
return value
.toString()
.split(",")
.map(entry => parseInt(entry.trim(), 10))
.map(entry => Number.parseInt(entry.trim(), 10))
.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 { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer/providers";
import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments/providers";
import type {
WhmcsGetPayMethodsParams,
WhmcsPaymentMethod,
WhmcsPaymentMethodListResponse,
WhmcsPaymentGateway,
@ -270,7 +270,7 @@ export class WhmcsPaymentService {
* Debug helper: log only the host of the SSO URL (never the token) in non-production.
*/
private debugLogRedirectHost(url: string): void {
if (process.env.NODE_ENV === "production") return;
if (process.env["NODE_ENV"] === "production") return;
try {
const target = new URL(url);
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.
*/
private debugLogRedirectHost(url: string): void {
if (process.env.NODE_ENV === "production") return;
if (process.env["NODE_ENV"] === "production") return;
try {
const target = new URL(url);
const base = new URL(this.connectionService.getBaseUrl());

View File

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

View File

@ -109,15 +109,14 @@ export class AuthFacade {
const profile = mapPrismaUserToDomain(prismaUser);
const userAgent = request?.headers?.["user-agent"];
const tokens = await this.tokenService.generateTokenPair(
{
id: profile.id,
email: profile.email,
role: prismaUser.role || "USER",
},
{
userAgent: request?.headers["user-agent"],
}
userAgent ? { userAgent } : {}
);
await this.updateAccountLastSignIn(user.id);
@ -290,7 +289,7 @@ export class AuthFacade {
async refreshTokens(
refreshToken: string | undefined,
deviceInfo?: { deviceId?: string; userAgent?: string }
deviceInfo?: { deviceId?: string | undefined; userAgent?: string | undefined }
) {
if (!refreshToken) {
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 { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth } from "@customer-portal/domain/customer";
export type RequestWithUser = Request & { user: User };

View File

@ -11,6 +11,7 @@ import type {
} from "@customer-portal/domain/get-started";
import { CacheService } from "@/infra/cache/cache.service.js";
import { DistributedLockService } from "@/infra/cache/distributed-lock.service.js";
/**
* 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"> {
/** Session ID (for lookup) */
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 {
private readonly SESSION_PREFIX = "get-started-session:";
private readonly HANDOFF_PREFIX = "guest-handoff:";
private readonly SESSION_LOCK_PREFIX = "session-lock:";
private readonly ttlSeconds: number;
private readonly handoffTtlSeconds = 1800; // 30 minutes for handoff tokens
constructor(
private readonly cache: CacheService,
private readonly lockService: DistributedLockService,
private readonly config: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
@ -277,6 +284,148 @@ export class GetStartedSessionService {
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 {
return `${this.SESSION_PREFIX}${sessionId}`;
}

View File

@ -17,7 +17,7 @@ interface OtpData {
/** When the code was created */
createdAt: string;
/** 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,
duration,
inMemoryBlockOnConsumed: limit + 1,
insuranceLimiter: undefined,
});
} catch (error: unknown) {
this.logger.error(

View File

@ -7,10 +7,10 @@ import { parseJwtExpiry } from "../../utils/jwt-expiry.util.js";
export class JoseJwtService {
private readonly signingKey: Uint8Array;
private readonly verificationKeys: Uint8Array[];
private readonly issuer?: string;
private readonly audience?: string | string[];
private readonly issuer: string | undefined;
private readonly audience: string | string[] | undefined;
constructor(private readonly configService: ConfigService) {
constructor(configService: ConfigService) {
const secret = configService.get<string>("JWT_SECRET");
if (!secret) {
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> {
const options = {
algorithms: ["HS256"],
issuer: this.issuer,
audience: this.audience,
algorithms: ["HS256"] as string[],
...(this.issuer === undefined ? {} : { issuer: this.issuer }),
...(this.audience === undefined ? {} : { audience: this.audience }),
};
let lastError: unknown;
for (let i = 0; i < this.verificationKeys.length; i++) {
const key = this.verificationKeys[i];
if (!key) continue;
try {
const { payload } = await jwtVerify(token, key, options);
return payload as T;

View File

@ -1,6 +1,5 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -32,8 +31,7 @@ export class TokenMigrationService {
constructor(
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger,
private readonly configService: ConfigService
@Inject(Logger) private readonly logger: Logger
) {}
/**
@ -415,7 +413,7 @@ export class TokenMigrationService {
}
const record = parsed as Record<string, unknown>;
const userId = record.userId;
const userId = record["userId"];
if (typeof userId !== "string" || userId.length === 0) {
this.logger.warn("Invalid family structure, skipping", { familyKey });
@ -448,8 +446,8 @@ export class TokenMigrationService {
}
const record = parsed as Record<string, unknown>;
const userId = record.userId;
const familyId = record.familyId;
const userId = record["userId"];
const familyId = record["familyId"];
if (typeof userId !== "string" || typeof familyId !== "string") {
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
*/
async getUserRefreshTokenFamilies(
userId: string
): Promise<
Array<{ familyId: string; deviceId?: string; userAgent?: string; createdAt?: string }>
async getUserRefreshTokenFamilies(userId: string): Promise<
Array<{
familyId: string;
deviceId?: string | undefined;
userAgent?: string | undefined;
createdAt?: string | undefined;
}>
> {
try {
const familyIds = await this.storage.getUserFamilyIds(userId);
const families: Array<{
familyId: string;
deviceId?: string;
userAgent?: string;
createdAt?: string;
deviceId?: string | undefined;
userAgent?: string | undefined;
createdAt?: string | undefined;
}> = [];
for (const familyId of familyIds) {

View File

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

View File

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

View File

@ -3,6 +3,8 @@ import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import * as argon2 from "argon2";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
import {
ACCOUNT_STATUS,
type AccountStatus,
@ -17,6 +19,7 @@ import {
type CompleteAccountRequest,
type MaybeLaterRequest,
type MaybeLaterResponse,
type SignupWithEligibilityRequest,
} from "@customer-portal/domain/get-started";
import { EmailService } from "@bff/infra/email/email.service.js";
@ -43,6 +46,15 @@ import {
} from "@bff/modules/auth/constants/portal.constants.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
*
@ -70,6 +82,7 @@ export class GetStartedWorkflowService {
private readonly whmcsSignup: SignupWhmcsService,
private readonly userCreation: SignupUserCreationService,
private readonly tokenService: AuthTokenService,
private readonly lockService: DistributedLockService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -155,15 +168,23 @@ export class GetStartedWorkflowService {
const prefill = this.getPrefillData(normalizedEmail, accountStatus);
// Update session with verified status and account info
await this.sessionService.markEmailVerified(sessionToken, accountStatus.status, {
firstName: prefill?.firstName,
lastName: prefill?.lastName,
phone: prefill?.phone,
address: prefill?.address,
sfAccountId: accountStatus.sfAccountId,
whmcsClientId: accountStatus.whmcsClientId,
eligibilityStatus: prefill?.eligibilityStatus,
});
// Build prefill data object without undefined values (exactOptionalPropertyTypes)
const prefillData = {
...(prefill?.firstName && { firstName: prefill.firstName }),
...(prefill?.lastName && { lastName: prefill.lastName }),
...(prefill?.phone && { phone: prefill.phone }),
...(prefill?.address && { address: prefill.address }),
...(accountStatus.sfAccountId && { sfAccountId: accountStatus.sfAccountId }),
...(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(
{ email: normalizedEmail, accountStatus: accountStatus.status },
@ -221,13 +242,13 @@ export class GetStartedWorkflowService {
// Create eligibility case
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, {
firstName,
lastName,
address,
phone,
sfAccountId,
address: removeUndefined(address),
...(phone && { phone }),
...(sfAccountId && { sfAccountId }),
});
return {
@ -286,6 +307,9 @@ export class GetStartedWorkflowService {
* Creates SF Account + eligibility case immediately.
* 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 fingerprint - Request fingerprint for logging/abuse detection
*/
@ -301,65 +325,74 @@ export class GetStartedWorkflowService {
"Guest eligibility check initiated"
);
// Email-level lock to prevent concurrent requests for the same email
const lockKey = `guest-eligibility:${normalizedEmail}`;
try {
// Check if SF account already exists for this email
let sfAccountId: string;
return await this.lockService.withLock(
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) {
sfAccountId = existingSf.id;
this.logger.log(
{ email: normalizedEmail, sfAccountId },
"Using existing SF account for guest eligibility check"
);
} else {
// Create new SF Account (email NOT verified)
const { accountId } = await this.salesforceAccountService.createAccount({
firstName,
lastName,
email: normalizedEmail,
phone: phone ?? "",
});
sfAccountId = accountId;
this.logger.log(
{ email: normalizedEmail, sfAccountId },
"Created SF account for guest eligibility check"
);
}
if (existingSf) {
sfAccountId = existingSf.id;
this.logger.log(
{ email: normalizedEmail, sfAccountId },
"Using existing SF account for guest eligibility check"
);
} else {
// Create new SF Account (email NOT verified)
const { accountId } = await this.salesforceAccountService.createAccount({
firstName,
lastName,
email: normalizedEmail,
phone: phone ?? "",
});
sfAccountId = accountId;
this.logger.log(
{ email: normalizedEmail, sfAccountId },
"Created SF account for guest eligibility check"
);
}
// Create eligibility case
const requestId = await this.createEligibilityCase(sfAccountId, address);
// Create eligibility case
const requestId = await this.createEligibilityCase(sfAccountId, address);
// Update Account eligibility status to Pending
this.updateAccountEligibilityStatus(sfAccountId);
// Update Account eligibility status to Pending
this.updateAccountEligibilityStatus(sfAccountId);
// If user wants to continue to account creation, generate a handoff token
let handoffToken: string | undefined;
if (continueToAccount) {
handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, {
firstName,
lastName,
address,
phone,
sfAccountId,
});
this.logger.debug(
{ email: normalizedEmail, handoffToken },
"Created handoff token for account creation"
);
}
// If user wants to continue to account creation, generate a handoff token
let handoffToken: string | undefined;
if (continueToAccount) {
handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, {
firstName,
lastName,
address: removeUndefined(address),
...(phone && { phone }),
sfAccountId,
});
this.logger.debug(
{ email: normalizedEmail, handoffToken },
"Created handoff token for account creation"
);
}
// Send confirmation email
await this.sendGuestEligibilityConfirmationEmail(normalizedEmail, firstName, requestId);
// Send confirmation email
await this.sendGuestEligibilityConfirmationEmail(normalizedEmail, firstName, requestId);
return {
submitted: true,
requestId,
sfAccountId,
handoffToken,
message: "Eligibility check submitted. We'll notify you of the results.",
};
return {
submitted: true,
requestId,
sfAccountId,
handoffToken,
message: "Eligibility check submitted. We'll notify you of the results.",
};
},
{ ttlMs: 30_000 } // 30 second lock timeout
);
} catch (error) {
this.logger.error(
{ error: extractErrorMessage(error), email: normalizedEmail },
@ -380,118 +413,334 @@ export class GetStartedWorkflowService {
/**
* Complete account for users with SF account but no WHMCS/Portal
* 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> {
const session = await this.sessionService.validateVerifiedSession(request.sessionToken);
if (!session) {
throw new BadRequestException("Invalid or expired session. Please verify your email again.");
// Atomically acquire session lock and mark as used
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
request.sessionToken,
"complete_account"
);
if (!sessionResult.success) {
throw new BadRequestException(sessionResult.reason);
}
const session = sessionResult.session;
if (!session.sfAccountId) {
throw new BadRequestException("No Salesforce account found. Please check eligibility first.");
}
const { password, phone, dateOfBirth, gender } = request;
// 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);
const lockKey = `complete-account:${session.email}`;
try {
// 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.");
}
return await this.lockService.withLock(
lockKey,
async () => {
// 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
const whmcsClient = await this.whmcsSignup.createClient({
firstName: session.firstName!,
lastName: session.lastName!,
email: session.email,
password,
phone,
address: {
address1: address.address1,
address2: address.address2 ?? undefined,
city: address.city,
state: address.state ?? "",
postcode: address.postcode,
country: address.country ?? "Japan",
// 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);
// 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,
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"
{ ttlMs: 60_000 }
);
return {
user: profile,
tokens,
};
} catch (error) {
this.logger.error(
{ error: extractErrorMessage(error), email: session.email },
"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;
}
}
// ============================================================================
// 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
// ============================================================================
@ -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(
email: string
): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> {

View File

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

View File

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

View File

@ -215,12 +215,12 @@ export class AuthController {
@Req() req: RequestWithCookies,
@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 userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined;
const result = await this.authFacade.refreshTokens(refreshToken, {
deviceId: body.deviceId,
userAgent,
deviceId: body.deviceId ?? undefined,
userAgent: userAgent ?? undefined,
});
this.setAuthCookies(res, result.tokens);
return { user: result.user, session: this.toSession(result.tokens) };

View File

@ -18,6 +18,8 @@ import {
completeAccountRequestSchema,
maybeLaterRequestSchema,
maybeLaterResponseSchema,
signupWithEligibilityRequestSchema,
signupWithEligibilityResponseSchema,
} from "@customer-portal/domain/get-started";
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 MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {}
class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {}
class SignupWithEligibilityRequestDto extends createZodDto(signupWithEligibilityRequestSchema) {}
class SignupWithEligibilityResponseDto extends createZodDto(signupWithEligibilityResponseSchema) {}
const ACCESS_COOKIE_PATH = "/api";
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
@ -177,7 +181,7 @@ export class GetStartedController {
res.cookie("access_token", result.tokens.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: process.env["NODE_ENV"] === "production",
sameSite: "lax",
path: ACCESS_COOKIE_PATH,
maxAge: calculateCookieMaxAge(accessExpires),
@ -185,7 +189,7 @@ export class GetStartedController {
res.cookie("refresh_token", result.tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: process.env["NODE_ENV"] === "production",
sameSite: "lax",
path: REFRESH_COOKIE_PATH,
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 body = (request.body ?? {}) as Record<string, unknown>;
const email = typeof body.email === "string" ? body.email : "";
const password = typeof body.password === "string" ? body.password : "";
const email = typeof body["email"] === "string" ? body["email"] : "";
const password = typeof body["password"] === "string" ? body["password"] : "";
if (!email || !password) {
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 valuePortion = trimmed.slice(0, -1);
const parsedValue = parseInt(valuePortion, 10);
const parsedValue = Number.parseInt(valuePortion, 10);
const toSeconds = (multiplier: number) => {
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 => {
const raw = headers?.authorization;
const raw = headers?.["authorization"];
return pickFirstStringHeader(raw);
};
@ -41,6 +41,6 @@ export const extractAccessTokenFromRequest = (request: RequestWithCookies): stri
const headerToken = extractBearerToken(request.headers);
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;
};

View File

@ -45,7 +45,7 @@ export class InvoiceRetrievalService {
const invoiceList = await this.whmcsInvoiceService.getInvoices(whmcsClientId, userId, {
page,
limit,
status,
...(status !== undefined && { status }),
});
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";
return withErrorHandling(
() => this.getInvoices(userId, { page, limit, status: queryStatus }),
async () => this.getInvoices(userId, { page, limit, status: queryStatus }),
this.logger,
{
context: `Get ${status} invoices for user ${userId}`,

View File

@ -20,10 +20,10 @@ export class HealthController {
// Database check
try {
await this.prisma.$queryRaw`SELECT 1`;
checks.database = "ok";
checks["database"] = "ok";
} catch (error) {
this.logger.error({ error }, "Database health check failed");
checks.database = "fail";
checks["database"] = "fail";
}
// Cache check
@ -31,11 +31,11 @@ export class HealthController {
const key = "health:check";
await this.cache.set(key, { ok: true }, 5);
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);
} catch (error) {
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";

View File

@ -57,7 +57,7 @@ export class MappingCacheService {
if (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}`);
}

View File

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

View File

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

View File

@ -80,7 +80,13 @@ export class MappingsService {
let created;
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) {
const msg = extractErrorMessage(e);
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({
where: { userId },
data: sanitizedUpdates,
data: prismaUpdateData,
});
const newMapping = mapPrismaMappingToDomain(updated);

View File

@ -186,32 +186,32 @@ export class MeStatusService {
// Priority 3: pending orders
if (orders && orders.length > 0) {
const pendingOrders = orders.filter(
const pendingOrder = orders.find(
o =>
o.status === "Draft" ||
o.status === "Pending" ||
(o.status === "Activated" && o.activationStatus !== "Completed")
);
if (pendingOrders.length > 0) {
const order = pendingOrders[0];
const firstPendingOrder = pendingOrder;
if (firstPendingOrder) {
const statusText =
order.status === "Pending"
firstPendingOrder.status === "Pending"
? "awaiting review"
: order.status === "Draft"
: firstPendingOrder.status === "Draft"
? "in draft"
: "being activated";
tasks.push({
id: `order-${order.id}`,
id: `order-${firstPendingOrder.id}`,
priority: 3,
type: "order",
title: "Order in progress",
description: `${order.orderType || "Your"} order is ${statusText}`,
description: `${firstPendingOrder.orderType || "Your"} order is ${statusText}`,
actionLabel: "View details",
detailHref: `/account/orders/${order.id}`,
detailHref: `/account/orders/${firstPendingOrder.id}`,
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 {
userId: string;
type: NotificationTypeValue;
title?: string;
message?: string;
actionUrl?: string;
actionLabel?: string;
source?: NotificationSourceValue;
sourceId?: string;
title?: string | undefined;
message?: string | undefined;
actionUrl?: string | undefined;
actionLabel?: string | undefined;
source?: NotificationSourceValue | undefined;
sourceId?: string | undefined;
}
@Injectable()

View File

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

View File

@ -47,6 +47,6 @@ export class ProvisioningQueueService {
async depth(): Promise<number> {
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: 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
if (selections.addonSku) refs.add(selections.addonSku);
if (selections.addons) {
selections.addons
for (const value of selections.addons
.split(",")
.map(value => value.trim())
.filter(Boolean)
.forEach(value => refs.add(value));
.filter(Boolean))
refs.add(value);
}
return Array.from(refs);
return [...refs];
}
/**

View File

@ -124,7 +124,7 @@ export class OrderBuilder {
try {
const profile = await this.usersFacade.getProfile(userId);
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>
| undefined;
const addressChanged = !!orderAddress;

View File

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

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