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:
parent
1d1602f5e7
commit
0f6bae840f
1
.husky/commit-msg
Executable file
1
.husky/commit-msg
Executable file
@ -0,0 +1 @@
|
||||
pnpm commitlint --edit $1
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
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";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
name: process.env.APP_NAME || "customer-portal-bff",
|
||||
// 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)
|
||||
@ -40,7 +38,7 @@ const prettyLogsEnabled =
|
||||
req: (req: { method?: string; url?: string; headers?: Record<string, unknown> }) => ({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
...(process.env.NODE_ENV === "development" && {
|
||||
...(process.env["NODE_ENV"] === "development" && {
|
||||
headers: req.headers,
|
||||
}),
|
||||
}),
|
||||
@ -48,18 +46,6 @@ const prettyLogsEnabled =
|
||||
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",
|
||||
@ -78,9 +64,25 @@ const prettyLogsEnabled =
|
||||
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: pinoHttpConfig })],
|
||||
exports: [LoggerModule],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
const createData: Parameters<typeof this.prisma.auditLog.create>[0]["data"] = {
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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> {
|
||||
|
||||
19
apps/bff/src/infra/cache/cache.service.ts
vendored
19
apps/bff/src/infra/cache/cache.service.ts
vendored
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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}`;
|
||||
};
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}`, {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) });
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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]`] || ""),
|
||||
|
||||
@ -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}`, {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -15,7 +15,6 @@ for (const signal of signals) {
|
||||
if (!app) {
|
||||
logger.warn("Nest application not initialized. Exiting immediately.");
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -133,7 +133,6 @@ export class AuthRateLimitService {
|
||||
points: limit,
|
||||
duration,
|
||||
inMemoryBlockOnConsumed: limit + 1,
|
||||
insuranceLimiter: undefined,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
// 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,
|
||||
eligibilityStatus: prefill?.eligibilityStatus,
|
||||
});
|
||||
}),
|
||||
...(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,7 +325,13 @@ export class GetStartedWorkflowService {
|
||||
"Guest eligibility check initiated"
|
||||
);
|
||||
|
||||
// Email-level lock to prevent concurrent requests for the same email
|
||||
const lockKey = `guest-eligibility:${normalizedEmail}`;
|
||||
|
||||
try {
|
||||
return await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
// Check if SF account already exists for this email
|
||||
let sfAccountId: string;
|
||||
|
||||
@ -340,8 +370,8 @@ export class GetStartedWorkflowService {
|
||||
handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, {
|
||||
firstName,
|
||||
lastName,
|
||||
address,
|
||||
phone,
|
||||
address: removeUndefined(address),
|
||||
...(phone && { phone }),
|
||||
sfAccountId,
|
||||
});
|
||||
this.logger.debug(
|
||||
@ -360,6 +390,9 @@ export class GetStartedWorkflowService {
|
||||
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,19 +413,35 @@ 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;
|
||||
const lockKey = `complete-account:${session.email}`;
|
||||
|
||||
try {
|
||||
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) {
|
||||
@ -415,7 +464,6 @@ export class GetStartedWorkflowService {
|
||||
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
try {
|
||||
// Get address from session or SF
|
||||
const address = session.address;
|
||||
if (!address || !address.address1 || !address.city || !address.postcode) {
|
||||
@ -431,7 +479,7 @@ export class GetStartedWorkflowService {
|
||||
phone,
|
||||
address: {
|
||||
address1: address.address1,
|
||||
address2: address.address2 ?? undefined,
|
||||
...(address.address2 && { address2: address.address2 }),
|
||||
city: address.city,
|
||||
state: address.state ?? "",
|
||||
postcode: address.postcode,
|
||||
@ -471,7 +519,7 @@ export class GetStartedWorkflowService {
|
||||
// Update Salesforce portal flags
|
||||
await this.updateSalesforcePortalFlags(session.sfAccountId, whmcsClient.clientId);
|
||||
|
||||
// Invalidate session
|
||||
// Invalidate session (fully done)
|
||||
await this.sessionService.invalidate(request.sessionToken);
|
||||
|
||||
this.logger.log(
|
||||
@ -483,15 +531,216 @@ export class GetStartedWorkflowService {
|
||||
user: profile,
|
||||
tokens,
|
||||
};
|
||||
},
|
||||
{ ttlMs: 60_000 }
|
||||
);
|
||||
} 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 }> {
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) };
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user