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
|
## Agent Behavior
|
||||||
|
|
||||||
|
**Always use `pnpm`** — never use `npm`, `yarn`, or `npx`:
|
||||||
|
|
||||||
|
- Use `pnpm exec` to run local binaries (e.g., `pnpm exec prisma migrate status`)
|
||||||
|
- Use `pnpm dlx` for one-off package execution (e.g., `pnpm dlx ts-prune`)
|
||||||
|
|
||||||
**Do NOT** run long-running processes without explicit permission:
|
**Do NOT** run long-running processes without explicit permission:
|
||||||
|
|
||||||
- `pnpm dev`, `pnpm dev:start`, `npm start`, `npm run dev`
|
- `pnpm dev`, `pnpm dev:start`, or any dev server commands
|
||||||
- Any command that starts servers, watchers, or blocking processes
|
- Any command that starts servers, watchers, or blocking processes
|
||||||
|
|
||||||
**Always ask first** before:
|
**Always ask first** before:
|
||||||
|
|||||||
@ -54,6 +54,7 @@
|
|||||||
"nestjs-zod": "^5.0.1",
|
"nestjs-zod": "^5.0.1",
|
||||||
"p-queue": "^9.0.1",
|
"p-queue": "^9.0.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"pino-http": "^11.0.0",
|
||||||
"prisma": "^7.1.0",
|
"prisma": "^7.1.0",
|
||||||
"rate-limiter-flexible": "^9.0.0",
|
"rate-limiter-flexible": "^9.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
|
import { APP_INTERCEPTOR, APP_PIPE, RouterModule } from "@nestjs/core";
|
||||||
import { RouterModule } from "@nestjs/core";
|
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { ZodSerializerInterceptor, ZodValidationPipe } from "nestjs-zod";
|
import { ZodSerializerInterceptor, ZodValidationPipe } from "nestjs-zod";
|
||||||
|
|||||||
@ -7,6 +7,10 @@ import helmet from "helmet";
|
|||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import type { CookieOptions, Response, NextFunction, Request } from "express";
|
import type { CookieOptions, Response, NextFunction, Request } from "express";
|
||||||
|
|
||||||
|
import { UnifiedExceptionFilter } from "../core/http/exception.filter.js";
|
||||||
|
|
||||||
|
import { AppModule } from "../app.module.js";
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-namespace */
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
@ -15,11 +19,6 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* eslint-enable @typescript-eslint/no-namespace */
|
|
||||||
|
|
||||||
import { UnifiedExceptionFilter } from "../core/http/exception.filter.js";
|
|
||||||
|
|
||||||
import { AppModule } from "../app.module.js";
|
|
||||||
|
|
||||||
export async function bootstrap(): Promise<INestApplication> {
|
export async function bootstrap(): Promise<INestApplication> {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { resolve } from "node:path";
|
|||||||
import type { ConfigModuleOptions } from "@nestjs/config";
|
import type { ConfigModuleOptions } from "@nestjs/config";
|
||||||
import { validate } from "./env.validation.js";
|
import { validate } from "./env.validation.js";
|
||||||
|
|
||||||
const nodeEnv = process.env.NODE_ENV || "development";
|
const nodeEnv = process.env["NODE_ENV"] || "development";
|
||||||
|
|
||||||
// pnpm sets cwd to the package directory (apps/bff) when running scripts
|
// pnpm sets cwd to the package directory (apps/bff) when running scripts
|
||||||
const bffRoot = process.cwd();
|
const bffRoot = process.cwd();
|
||||||
|
|||||||
@ -12,17 +12,17 @@ export interface DevAuthConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const createDevAuthConfig = (): DevAuthConfig => {
|
export const createDevAuthConfig = (): DevAuthConfig => {
|
||||||
const isDevelopment = process.env.NODE_ENV !== "production";
|
const isDevelopment = process.env["NODE_ENV"] !== "production";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Disable CSRF protection in development for easier testing
|
// Disable CSRF protection in development for easier testing
|
||||||
disableCsrf: isDevelopment && process.env.DISABLE_CSRF === "true",
|
disableCsrf: isDevelopment && process.env["DISABLE_CSRF"] === "true",
|
||||||
|
|
||||||
// Disable rate limiting in development
|
// Disable rate limiting in development
|
||||||
disableRateLimit: isDevelopment && process.env.DISABLE_RATE_LIMIT === "true",
|
disableRateLimit: isDevelopment && process.env["DISABLE_RATE_LIMIT"] === "true",
|
||||||
|
|
||||||
// Disable account locking in development
|
// Disable account locking in development
|
||||||
disableAccountLocking: isDevelopment && process.env.DISABLE_ACCOUNT_LOCKING === "true",
|
disableAccountLocking: isDevelopment && process.env["DISABLE_ACCOUNT_LOCKING"] === "true",
|
||||||
|
|
||||||
// Enable debug logs in development
|
// Enable debug logs in development
|
||||||
enableDebugLogs: isDevelopment,
|
enableDebugLogs: isDevelopment,
|
||||||
|
|||||||
@ -47,11 +47,11 @@ function mapHttpStatusToErrorCode(status?: number): ErrorCodeType {
|
|||||||
*/
|
*/
|
||||||
interface ErrorContext {
|
interface ErrorContext {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
userId?: string;
|
userId?: string | undefined;
|
||||||
method: string;
|
method: string;
|
||||||
path: string;
|
path: string;
|
||||||
userAgent?: string;
|
userAgent?: string | undefined;
|
||||||
ip?: string;
|
ip?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -123,7 +123,7 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
|
|||||||
*/
|
*/
|
||||||
private extractExceptionDetails(exception: HttpException): {
|
private extractExceptionDetails(exception: HttpException): {
|
||||||
message: string;
|
message: string;
|
||||||
code?: ErrorCodeType;
|
code?: ErrorCodeType | undefined;
|
||||||
} {
|
} {
|
||||||
const response = exception.getResponse();
|
const response = exception.getResponse();
|
||||||
|
|
||||||
@ -136,19 +136,21 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
|
|||||||
const code = this.extractExplicitCode(responseObj);
|
const code = this.extractExplicitCode(responseObj);
|
||||||
|
|
||||||
// Handle NestJS validation errors (array of messages)
|
// Handle NestJS validation errors (array of messages)
|
||||||
if (Array.isArray(responseObj.message)) {
|
const messageField = responseObj["message"];
|
||||||
const firstMessage = responseObj.message.find((m): m is string => typeof m === "string");
|
if (Array.isArray(messageField)) {
|
||||||
|
const firstMessage = messageField.find((m): m is string => typeof m === "string");
|
||||||
if (firstMessage) return { message: firstMessage, code };
|
if (firstMessage) return { message: firstMessage, code };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle standard message field
|
// Handle standard message field
|
||||||
if (typeof responseObj.message === "string") {
|
if (typeof messageField === "string") {
|
||||||
return { message: responseObj.message, code };
|
return { message: messageField, code };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle error field
|
// Handle error field
|
||||||
if (typeof responseObj.error === "string") {
|
const errorField = responseObj["error"];
|
||||||
return { message: responseObj.error, code };
|
if (typeof errorField === "string") {
|
||||||
|
return { message: errorField, code };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { message: exception.message, code };
|
return { message: exception.message, code };
|
||||||
@ -162,16 +164,18 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
|
|||||||
*/
|
*/
|
||||||
private extractExplicitCode(responseObj: Record<string, unknown>): ErrorCodeType | undefined {
|
private extractExplicitCode(responseObj: Record<string, unknown>): ErrorCodeType | undefined {
|
||||||
// 1) Preferred: { code: "AUTH_003" }
|
// 1) Preferred: { code: "AUTH_003" }
|
||||||
if (typeof responseObj.code === "string" && this.isKnownErrorCode(responseObj.code)) {
|
const codeField = responseObj["code"];
|
||||||
return responseObj.code as ErrorCodeType;
|
if (typeof codeField === "string" && this.isKnownErrorCode(codeField)) {
|
||||||
|
return codeField as ErrorCodeType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Standard API error format: { error: { code: "AUTH_003" } }
|
// 2) Standard API error format: { error: { code: "AUTH_003" } }
|
||||||
const maybeError = responseObj.error;
|
const maybeError = responseObj["error"];
|
||||||
if (maybeError && typeof maybeError === "object") {
|
if (maybeError && typeof maybeError === "object") {
|
||||||
const errorObj = maybeError as Record<string, unknown>;
|
const errorObj = maybeError as Record<string, unknown>;
|
||||||
if (typeof errorObj.code === "string" && this.isKnownErrorCode(errorObj.code)) {
|
const errorCode = errorObj["code"];
|
||||||
return errorObj.code as ErrorCodeType;
|
if (typeof errorCode === "string" && this.isKnownErrorCode(errorCode)) {
|
||||||
|
return errorCode as ErrorCodeType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,7 +213,7 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
|
|||||||
.replace(/secret[=:]\s*[^\s]+/gi, "secret=[HIDDEN]")
|
.replace(/secret[=:]\s*[^\s]+/gi, "secret=[HIDDEN]")
|
||||||
.replace(/token[=:]\s*[^\s]+/gi, "token=[HIDDEN]")
|
.replace(/token[=:]\s*[^\s]+/gi, "token=[HIDDEN]")
|
||||||
.replace(/key[=:]\s*[^\s]+/gi, "key=[HIDDEN]")
|
.replace(/key[=:]\s*[^\s]+/gi, "key=[HIDDEN]")
|
||||||
.substring(0, 200); // Limit length
|
.slice(0, 200); // Limit length
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
export interface RequestContextLike {
|
export interface RequestContextLike {
|
||||||
headers?: Record<string, string | string[] | undefined>;
|
headers?: Record<string, string | string[] | undefined> | undefined;
|
||||||
ip?: string;
|
ip?: string | undefined;
|
||||||
connection?: { remoteAddress?: string };
|
connection?: { remoteAddress?: string | undefined } | undefined;
|
||||||
socket?: { remoteAddress?: string };
|
socket?: { remoteAddress?: string | undefined } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractClientIp(request?: RequestContextLike): string {
|
export function extractClientIp(request?: RequestContextLike): string {
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
import { Global, Module } from "@nestjs/common";
|
import { Global, Module } from "@nestjs/common";
|
||||||
import { LoggerModule } from "nestjs-pino";
|
import { LoggerModule } from "nestjs-pino";
|
||||||
|
import type { Options as PinoHttpOptions } from "pino-http";
|
||||||
|
|
||||||
const prettyLogsEnabled =
|
const prettyLogsEnabled =
|
||||||
process.env.PRETTY_LOGS === "true" || process.env.NODE_ENV !== "production";
|
process.env["PRETTY_LOGS"] === "true" || process.env["NODE_ENV"] !== "production";
|
||||||
|
|
||||||
@Global()
|
// Build pinoHttp config - extracted to avoid type issues with exactOptionalPropertyTypes
|
||||||
@Module({
|
const pinoHttpConfig: PinoHttpOptions = {
|
||||||
imports: [
|
level: process.env["LOG_LEVEL"] || "info",
|
||||||
LoggerModule.forRoot({
|
name: process.env["APP_NAME"] || "customer-portal-bff",
|
||||||
pinoHttp: {
|
|
||||||
level: process.env.LOG_LEVEL || "info",
|
|
||||||
name: process.env.APP_NAME || "customer-portal-bff",
|
|
||||||
/**
|
/**
|
||||||
* Reduce noise from pino-http auto logging:
|
* Reduce noise from pino-http auto logging:
|
||||||
* - successful requests => debug (hidden when LOG_LEVEL=info)
|
* - successful requests => debug (hidden when LOG_LEVEL=info)
|
||||||
@ -40,7 +38,7 @@ const prettyLogsEnabled =
|
|||||||
req: (req: { method?: string; url?: string; headers?: Record<string, unknown> }) => ({
|
req: (req: { method?: string; url?: string; headers?: Record<string, unknown> }) => ({
|
||||||
method: req.method,
|
method: req.method,
|
||||||
url: req.url,
|
url: req.url,
|
||||||
...(process.env.NODE_ENV === "development" && {
|
...(process.env["NODE_ENV"] === "development" && {
|
||||||
headers: req.headers,
|
headers: req.headers,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@ -48,18 +46,6 @@ const prettyLogsEnabled =
|
|||||||
statusCode: res.statusCode,
|
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: {
|
redact: {
|
||||||
paths: [
|
paths: [
|
||||||
"req.headers.authorization",
|
"req.headers.authorization",
|
||||||
@ -78,9 +64,25 @@ const prettyLogsEnabled =
|
|||||||
level: (label: string) => ({ level: label }),
|
level: (label: string) => ({ level: label }),
|
||||||
bindings: () => ({}),
|
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],
|
exports: [LoggerModule],
|
||||||
})
|
})
|
||||||
export class LoggingModule {}
|
export class LoggingModule {}
|
||||||
|
|||||||
@ -31,17 +31,14 @@ type CsrfRequest = Omit<BaseExpressRequest, "cookies"> & {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CsrfMiddleware implements NestMiddleware {
|
export class CsrfMiddleware implements NestMiddleware {
|
||||||
private readonly isProduction: boolean;
|
|
||||||
private readonly exemptPaths: Set<string>;
|
private readonly exemptPaths: Set<string>;
|
||||||
private readonly safeMethods: Set<string>;
|
private readonly safeMethods: Set<string>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly csrfService: CsrfService,
|
private readonly csrfService: CsrfService,
|
||||||
private readonly configService: ConfigService,
|
_configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
this.isProduction = this.configService.get("NODE_ENV") === "production";
|
|
||||||
|
|
||||||
// Paths that don't require CSRF protection
|
// Paths that don't require CSRF protection
|
||||||
this.exemptPaths = new Set([
|
this.exemptPaths = new Set([
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
@ -94,7 +91,7 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateCsrfToken(req: CsrfRequest, res: Response, next: NextFunction): void {
|
private validateCsrfToken(req: CsrfRequest, _res: Response, next: NextFunction): void {
|
||||||
const token = this.extractTokenFromRequest(req);
|
const token = this.extractTokenFromRequest(req);
|
||||||
const secret = this.extractSecretFromCookie(req);
|
const secret = this.extractSecretFromCookie(req);
|
||||||
const sessionId = req.user?.sessionId || this.extractSessionId(req);
|
const sessionId = req.user?.sessionId || this.extractSessionId(req);
|
||||||
|
|||||||
@ -7,13 +7,13 @@ export interface CsrfTokenData {
|
|||||||
token: string;
|
token: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
sessionId?: string;
|
sessionId?: string | undefined;
|
||||||
userId?: string;
|
userId?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CsrfValidationResult {
|
export interface CsrfValidationResult {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
reason?: string;
|
reason?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CsrfTokenStats {
|
export interface CsrfTokenStats {
|
||||||
@ -191,7 +191,7 @@ export class CsrfService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private hashToken(token: string): string {
|
private hashToken(token: string): string {
|
||||||
return crypto.createHash("sha256").update(token).digest("hex").substring(0, 16);
|
return crypto.createHash("sha256").update(token).digest("hex").slice(0, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
private constantTimeEquals(a: string, b: string): boolean {
|
private constantTimeEquals(a: string, b: string): boolean {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export function isErrorWithMessage(error: unknown): error is Error {
|
|||||||
typeof error === "object" &&
|
typeof error === "object" &&
|
||||||
error !== null &&
|
error !== null &&
|
||||||
Object.hasOwn(error, "message") &&
|
Object.hasOwn(error, "message") &&
|
||||||
typeof (error as Record<string, unknown>).message === "string"
|
typeof (error as Record<string, unknown>)["message"] === "string"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,8 +19,8 @@ export function isErrorWithMessage(error: unknown): error is Error {
|
|||||||
* Enhanced error type with common error properties
|
* Enhanced error type with common error properties
|
||||||
*/
|
*/
|
||||||
interface EnhancedError extends Error {
|
interface EnhancedError extends Error {
|
||||||
code?: string;
|
code?: string | undefined;
|
||||||
statusCode?: number;
|
statusCode?: number | undefined;
|
||||||
cause?: unknown;
|
cause?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -71,8 +71,10 @@ export function calculateBackoffDelay(
|
|||||||
/**
|
/**
|
||||||
* Promise-based sleep utility
|
* Promise-based sleep utility
|
||||||
*/
|
*/
|
||||||
export function sleep(ms: number): Promise<void> {
|
export async function sleep(ms: number): Promise<void> {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -22,16 +22,28 @@ export enum AuditAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogData {
|
export interface AuditLogData {
|
||||||
userId?: string;
|
userId?: string | undefined;
|
||||||
action: AuditAction;
|
action: AuditAction;
|
||||||
resource?: string;
|
resource?: string | undefined;
|
||||||
details?: Record<string, unknown> | string | number | boolean | null;
|
details?: Record<string, unknown> | string | number | boolean | null | undefined;
|
||||||
ipAddress?: string;
|
ipAddress?: string | undefined;
|
||||||
userAgent?: string;
|
userAgent?: string | undefined;
|
||||||
success?: boolean;
|
success?: boolean | undefined;
|
||||||
error?: string;
|
error?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal request shape for audit logging.
|
||||||
|
* Compatible with Express Request but only requires the fields needed for IP/UA extraction.
|
||||||
|
* Must be compatible with RequestContextLike from request-context.util.ts.
|
||||||
|
*/
|
||||||
|
export type AuditRequest = {
|
||||||
|
headers?: Record<string, string | string[] | undefined> | undefined;
|
||||||
|
ip?: string | undefined;
|
||||||
|
connection?: { remoteAddress?: string | undefined } | undefined;
|
||||||
|
socket?: { remoteAddress?: string | undefined } | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuditService {
|
export class AuditService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -41,23 +53,24 @@ export class AuditService {
|
|||||||
|
|
||||||
async log(data: AuditLogData): Promise<void> {
|
async log(data: AuditLogData): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.prisma.auditLog.create({
|
const createData: Parameters<typeof this.prisma.auditLog.create>[0]["data"] = {
|
||||||
data: {
|
|
||||||
userId: data.userId,
|
|
||||||
action: data.action,
|
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,
|
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) {
|
} catch (error) {
|
||||||
this.logger.error("Audit logging failed", {
|
this.logger.error("Audit logging failed", {
|
||||||
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
||||||
@ -70,12 +83,7 @@ export class AuditService {
|
|||||||
action: AuditAction,
|
action: AuditAction,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
details?: Record<string, unknown> | string | number | boolean | null,
|
details?: Record<string, unknown> | string | number | boolean | null,
|
||||||
request?: {
|
request?: AuditRequest,
|
||||||
headers?: Record<string, string | string[] | undefined>;
|
|
||||||
ip?: string;
|
|
||||||
connection?: { remoteAddress?: string };
|
|
||||||
socket?: { remoteAddress?: string };
|
|
||||||
},
|
|
||||||
success: boolean = true,
|
success: boolean = true,
|
||||||
error?: string
|
error?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
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> {
|
async delPattern(pattern: string): Promise<void> {
|
||||||
const pipeline = this.redis.pipeline();
|
const pipeline = this.redis.pipeline();
|
||||||
let pending = 0;
|
const state = { pending: 0 };
|
||||||
|
|
||||||
const flush = async () => {
|
const flush = async () => {
|
||||||
if (pending === 0) {
|
if (state.pending === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await pipeline.exec();
|
await pipeline.exec();
|
||||||
pending = 0;
|
// eslint-disable-next-line require-atomic-updates -- flush is called sequentially, no concurrent access
|
||||||
|
state.pending = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.scanPattern(pattern, async keys => {
|
await this.scanPattern(pattern, async keys => {
|
||||||
keys.forEach(key => {
|
for (const key of keys) {
|
||||||
pipeline.del(key);
|
pipeline.del(key);
|
||||||
pending += 1;
|
state.pending += 1;
|
||||||
});
|
}
|
||||||
if (pending >= 1000) {
|
if (state.pending >= 1000) {
|
||||||
await flush();
|
await flush();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -122,9 +123,9 @@ export class CacheService {
|
|||||||
let total = 0;
|
let total = 0;
|
||||||
await this.scanPattern(pattern, async keys => {
|
await this.scanPattern(pattern, async keys => {
|
||||||
const pipeline = this.redis.pipeline();
|
const pipeline = this.redis.pipeline();
|
||||||
keys.forEach(key => {
|
for (const key of keys) {
|
||||||
pipeline.memory("USAGE", key);
|
pipeline.memory("USAGE", key);
|
||||||
});
|
}
|
||||||
const results = await pipeline.exec();
|
const results = await pipeline.exec();
|
||||||
if (!results) {
|
if (!results) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -65,7 +65,7 @@ export class DistributedLockService {
|
|||||||
return {
|
return {
|
||||||
key: lockKey,
|
key: lockKey,
|
||||||
token,
|
token,
|
||||||
release: () => this.release(lockKey, token),
|
release: async () => this.release(lockKey, token),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +183,9 @@ export class DistributedLockService {
|
|||||||
/**
|
/**
|
||||||
* Delay helper
|
* Delay helper
|
||||||
*/
|
*/
|
||||||
private delay(ms: number): Promise<void> {
|
private async delay(ms: number): Promise<void> {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,12 +17,11 @@ import { PrismaPg } from "@prisma/adapter-pg";
|
|||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(PrismaService.name);
|
private readonly logger = new Logger(PrismaService.name);
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
private readonly instanceTag: string;
|
|
||||||
private destroyCalls = 0;
|
private destroyCalls = 0;
|
||||||
private poolEnded = false;
|
private poolEnded = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const connectionString = process.env.DATABASE_URL;
|
const connectionString = process.env["DATABASE_URL"];
|
||||||
if (!connectionString) {
|
if (!connectionString) {
|
||||||
throw new Error("DATABASE_URL environment variable is required");
|
throw new Error("DATABASE_URL environment variable is required");
|
||||||
}
|
}
|
||||||
@ -43,7 +42,6 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
|||||||
super({ adapter });
|
super({ adapter });
|
||||||
|
|
||||||
this.pool = pool;
|
this.pool = pool;
|
||||||
this.instanceTag = `${process.pid}:${Date.now()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
|
|||||||
@ -24,8 +24,8 @@ export interface DistributedTransactionResult<
|
|||||||
TStepResults extends StepResultMap = StepResultMap,
|
TStepResults extends StepResultMap = StepResultMap,
|
||||||
> {
|
> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: TData;
|
data?: TData | undefined;
|
||||||
error?: string;
|
error?: string | undefined;
|
||||||
duration: number;
|
duration: number;
|
||||||
stepsExecuted: number;
|
stepsExecuted: number;
|
||||||
stepsRolledBack: number;
|
stepsRolledBack: number;
|
||||||
@ -254,7 +254,7 @@ export class DistributedTransactionService {
|
|||||||
databaseOperation,
|
databaseOperation,
|
||||||
{
|
{
|
||||||
description: `${options.description} - Database Operations`,
|
description: `${options.description} - Database Operations`,
|
||||||
timeout: options.timeout,
|
...(options.timeout !== undefined && { timeout: options.timeout }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -305,7 +305,7 @@ export class DistributedTransactionService {
|
|||||||
databaseOperation,
|
databaseOperation,
|
||||||
{
|
{
|
||||||
description: `${options.description} - Database Operations`,
|
description: `${options.description} - Database Operations`,
|
||||||
timeout: options.timeout,
|
...(options.timeout !== undefined && { timeout: options.timeout }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -442,6 +442,6 @@ export class DistributedTransactionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateTransactionId(): string {
|
private generateTransactionId(): string {
|
||||||
return `dtx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
return `dtx_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,36 +28,41 @@ export interface TransactionOptions {
|
|||||||
* Maximum time to wait for transaction to complete (ms)
|
* Maximum time to wait for transaction to complete (ms)
|
||||||
* Default: 30 seconds
|
* Default: 30 seconds
|
||||||
*/
|
*/
|
||||||
timeout?: number;
|
timeout?: number | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum number of retry attempts on serialization failures
|
* Maximum number of retry attempts on serialization failures
|
||||||
* Default: 3
|
* Default: 3
|
||||||
*/
|
*/
|
||||||
maxRetries?: number;
|
maxRetries?: number | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom isolation level for the transaction
|
* Custom isolation level for the transaction
|
||||||
* Default: ReadCommitted
|
* Default: ReadCommitted
|
||||||
*/
|
*/
|
||||||
isolationLevel?: "ReadUncommitted" | "ReadCommitted" | "RepeatableRead" | "Serializable";
|
isolationLevel?:
|
||||||
|
| "ReadUncommitted"
|
||||||
|
| "ReadCommitted"
|
||||||
|
| "RepeatableRead"
|
||||||
|
| "Serializable"
|
||||||
|
| undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Description of the transaction for logging
|
* Description of the transaction for logging
|
||||||
*/
|
*/
|
||||||
description?: string;
|
description?: string | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to automatically rollback external operations on database rollback
|
* Whether to automatically rollback external operations on database rollback
|
||||||
* Default: true
|
* Default: true
|
||||||
*/
|
*/
|
||||||
autoRollback?: boolean;
|
autoRollback?: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransactionResult<T> {
|
export interface TransactionResult<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: T;
|
data?: T | undefined;
|
||||||
error?: string;
|
error?: string | undefined;
|
||||||
duration: number;
|
duration: number;
|
||||||
operationsCount: number;
|
operationsCount: number;
|
||||||
rollbacksExecuted: number;
|
rollbacksExecuted: number;
|
||||||
@ -289,8 +294,10 @@ export class TransactionService {
|
|||||||
|
|
||||||
// Execute rollbacks in reverse order (LIFO)
|
// Execute rollbacks in reverse order (LIFO)
|
||||||
for (let i = context.rollbackActions.length - 1; i >= 0; i--) {
|
for (let i = context.rollbackActions.length - 1; i >= 0; i--) {
|
||||||
|
const rollbackAction = context.rollbackActions[i];
|
||||||
|
if (!rollbackAction) continue;
|
||||||
try {
|
try {
|
||||||
await context.rollbackActions[i]();
|
await rollbackAction();
|
||||||
rollbacksExecuted++;
|
rollbacksExecuted++;
|
||||||
this.logger.debug(`Rollback ${i + 1} completed [${context.id}]`);
|
this.logger.debug(`Rollback ${i + 1} completed [${context.id}]`);
|
||||||
} catch (rollbackError) {
|
} catch (rollbackError) {
|
||||||
@ -321,11 +328,13 @@ export class TransactionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async delay(ms: number): Promise<void> {
|
private async delay(ms: number): Promise<void> {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateTransactionId(): string {
|
private generateTransactionId(): string {
|
||||||
return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
return `tx_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -19,8 +19,8 @@ export interface ProviderSendOptions {
|
|||||||
|
|
||||||
export interface SendGridErrorDetail {
|
export interface SendGridErrorDetail {
|
||||||
message: string;
|
message: string;
|
||||||
field?: string;
|
field?: string | undefined;
|
||||||
help?: string;
|
help?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedSendGridError {
|
export interface ParsedSendGridError {
|
||||||
@ -215,8 +215,8 @@ export class SendGridEmailProvider implements OnModuleInit {
|
|||||||
private maskEmail(email: string | string[]): string | string[] {
|
private maskEmail(email: string | string[]): string | string[] {
|
||||||
const mask = (e: string): string => {
|
const mask = (e: string): string => {
|
||||||
const [local, domain] = e.split("@");
|
const [local, domain] = e.split("@");
|
||||||
if (!domain) return "***";
|
if (!domain || !local) return "***";
|
||||||
const maskedLocal = local.length > 2 ? `${local[0]}***${local[local.length - 1]}` : "***";
|
const maskedLocal = local.length > 2 ? `${local[0]}***${local.at(-1)}` : "***";
|
||||||
return `${maskedLocal}@${domain}`;
|
return `${maskedLocal}@${domain}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ function parseRedisConnection(redisUrl: string) {
|
|||||||
host: url.hostname,
|
host: url.hostname,
|
||||||
port: Number(url.port || (isTls ? 6380 : 6379)),
|
port: Number(url.port || (isTls ? 6380 : 6379)),
|
||||||
password: url.password || undefined,
|
password: url.password || undefined,
|
||||||
...(db !== undefined ? { db } : {}),
|
...(db === undefined ? {} : { db }),
|
||||||
...(isTls ? { tls: {} } : {}),
|
...(isTls ? { tls: {} } : {}),
|
||||||
} as Record<string, unknown>;
|
} as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export type DegradationReason = "rate-limit" | "usage-threshold" | "queue-pressu
|
|||||||
export interface SalesforceDegradationSnapshot {
|
export interface SalesforceDegradationSnapshot {
|
||||||
degraded: boolean;
|
degraded: boolean;
|
||||||
reason: DegradationReason | null;
|
reason: DegradationReason | null;
|
||||||
cooldownExpiresAt?: Date;
|
cooldownExpiresAt?: Date | undefined;
|
||||||
usagePercent: number;
|
usagePercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,8 +118,7 @@ export class SalesforceQueueDegradationService {
|
|||||||
this.activateDegradeWindow("usage-threshold");
|
this.activateDegradeWindow("usage-threshold");
|
||||||
}
|
}
|
||||||
|
|
||||||
const threshold = this.usageWarningLevels
|
const threshold = [...this.usageWarningLevels]
|
||||||
.slice()
|
|
||||||
.reverse()
|
.reverse()
|
||||||
.find(level => usagePercent >= level && level > this.highestUsageWarningIssued);
|
.find(level => usagePercent >= level && level > this.highestUsageWarningIssued);
|
||||||
|
|
||||||
|
|||||||
@ -8,16 +8,16 @@ export interface SalesforceRouteMetricsInternal {
|
|||||||
label: string;
|
label: string;
|
||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
failedRequests: number;
|
failedRequests: number;
|
||||||
lastSuccessTime?: Date;
|
lastSuccessTime?: Date | undefined;
|
||||||
lastErrorTime?: Date;
|
lastErrorTime?: Date | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SalesforceRouteMetricsSnapshot {
|
export interface SalesforceRouteMetricsSnapshot {
|
||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
failedRequests: number;
|
failedRequests: number;
|
||||||
successRate: number;
|
successRate: number;
|
||||||
lastSuccessTime?: Date;
|
lastSuccessTime?: Date | undefined;
|
||||||
lastErrorTime?: Date;
|
lastErrorTime?: Date | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SalesforceQueueMetricsData {
|
export interface SalesforceQueueMetricsData {
|
||||||
@ -29,9 +29,9 @@ export interface SalesforceQueueMetricsData {
|
|||||||
averageWaitTime: number;
|
averageWaitTime: number;
|
||||||
averageExecutionTime: number;
|
averageExecutionTime: number;
|
||||||
dailyApiUsage: number;
|
dailyApiUsage: number;
|
||||||
lastRequestTime?: Date;
|
lastRequestTime?: Date | undefined;
|
||||||
lastErrorTime?: Date;
|
lastErrorTime?: Date | undefined;
|
||||||
lastRateLimitTime?: Date;
|
lastRateLimitTime?: Date | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -52,7 +52,10 @@ export class SalesforceQueueMetricsService {
|
|||||||
dailyApiUsage: 0,
|
dailyApiUsage: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(@Inject(Logger) private readonly logger: Logger) {}
|
constructor(@Inject(Logger) _logger: Logger) {
|
||||||
|
// Logger available for future use in metrics logging
|
||||||
|
void _logger;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current metrics data
|
* Get current metrics data
|
||||||
|
|||||||
@ -22,13 +22,13 @@ export interface SalesforceQueueMetrics {
|
|||||||
averageWaitTime: number;
|
averageWaitTime: number;
|
||||||
averageExecutionTime: number;
|
averageExecutionTime: number;
|
||||||
dailyApiUsage: number;
|
dailyApiUsage: number;
|
||||||
lastRequestTime?: Date;
|
lastRequestTime?: Date | undefined;
|
||||||
lastErrorTime?: Date;
|
lastErrorTime?: Date | undefined;
|
||||||
lastRateLimitTime?: Date;
|
lastRateLimitTime?: Date | undefined;
|
||||||
dailyApiLimit?: number;
|
dailyApiLimit?: number | undefined;
|
||||||
dailyUsagePercent?: number;
|
dailyUsagePercent?: number | undefined;
|
||||||
routeBreakdown?: Record<string, SalesforceRouteMetricsSnapshot>;
|
routeBreakdown?: Record<string, SalesforceRouteMetricsSnapshot> | undefined;
|
||||||
degradation?: SalesforceDegradationSnapshot;
|
degradation?: SalesforceDegradationSnapshot | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SalesforceRequestOptions {
|
export interface SalesforceRequestOptions {
|
||||||
@ -480,7 +480,9 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
|||||||
error: lastError.message,
|
error: lastError.message,
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
await new Promise(resolve => {
|
||||||
|
setTimeout(resolve, delay);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,10 +35,10 @@ export interface WhmcsQueueMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WhmcsRequestOptions {
|
export interface WhmcsRequestOptions {
|
||||||
priority?: number; // Higher number = higher priority (0-10)
|
priority?: number | undefined; // Higher number = higher priority (0-10)
|
||||||
timeout?: number; // Request timeout in ms
|
timeout?: number | undefined; // Request timeout in ms
|
||||||
retryAttempts?: number; // Number of retry attempts
|
retryAttempts?: number | undefined; // Number of retry attempts
|
||||||
retryDelay?: number; // Base delay between retries in ms
|
retryDelay?: number | undefined; // Base delay between retries in ms
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export class RealtimePubSubService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
publish(message: RealtimePubSubMessage): Promise<number> {
|
async publish(message: RealtimePubSubMessage): Promise<number> {
|
||||||
return this.redis.publish(this.CHANNEL, JSON.stringify(message));
|
return this.redis.publish(this.CHANNEL, JSON.stringify(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -109,7 +109,7 @@ export class RealtimeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const evt = this.buildMessage(message.event, message.data);
|
const evt = this.buildMessage(message.event, message.data);
|
||||||
set.forEach(observer => {
|
for (const observer of set) {
|
||||||
try {
|
try {
|
||||||
observer.next(evt);
|
observer.next(evt);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -118,7 +118,7 @@ export class RealtimeService {
|
|||||||
error: extractErrorMessage(error),
|
error: extractErrorMessage(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildMessage<TEvent extends string>(event: TEvent, data: unknown): MessageEvent {
|
private buildMessage<TEvent extends string>(event: TEvent, data: unknown): MessageEvent {
|
||||||
|
|||||||
@ -35,8 +35,8 @@ export class FreebitAccountService {
|
|||||||
|
|
||||||
const config = this.auth.getConfig();
|
const config = this.auth.getConfig();
|
||||||
const configured = config.detailsEndpoint || "/master/getAcnt/";
|
const configured = config.detailsEndpoint || "/master/getAcnt/";
|
||||||
const candidates = Array.from(
|
const candidates = [
|
||||||
new Set([
|
...new Set([
|
||||||
configured,
|
configured,
|
||||||
configured.replace(/\/$/, ""),
|
configured.replace(/\/$/, ""),
|
||||||
"/master/getAcnt/",
|
"/master/getAcnt/",
|
||||||
@ -53,8 +53,8 @@ export class FreebitAccountService {
|
|||||||
"/mvno/getInfo",
|
"/mvno/getInfo",
|
||||||
"/master/getDetail/",
|
"/master/getDetail/",
|
||||||
"/master/getDetail",
|
"/master/getDetail",
|
||||||
])
|
]),
|
||||||
);
|
];
|
||||||
|
|
||||||
let response: FreebitAccountDetailsResponse | undefined;
|
let response: FreebitAccountDetailsResponse | undefined;
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|||||||
@ -80,9 +80,9 @@ export class FreebitAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as FreebitAuthResponse;
|
const data = (await response.json()) as FreebitAuthResponse;
|
||||||
const resultCode = data?.resultCode != null ? String(data.resultCode).trim() : undefined;
|
const resultCode = data?.resultCode == null ? undefined : String(data.resultCode).trim();
|
||||||
const statusCode =
|
const statusCode =
|
||||||
data?.status?.statusCode != null ? String(data.status.statusCode).trim() : undefined;
|
data?.status?.statusCode == null ? undefined : String(data.status.statusCode).trim();
|
||||||
|
|
||||||
if (resultCode !== "100") {
|
if (resultCode !== "100") {
|
||||||
throw new FreebitError(
|
throw new FreebitError(
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export class FreebitCancellationService {
|
|||||||
await this.rateLimiter.executeWithSpacing(account, "cancellation", async () => {
|
await this.rateLimiter.executeWithSpacing(account, "cancellation", async () => {
|
||||||
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
|
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
runTime: scheduledAt,
|
...(scheduledAt !== undefined && { runTime: scheduledAt }),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, {
|
this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, {
|
||||||
@ -75,7 +75,7 @@ export class FreebitCancellationService {
|
|||||||
const request: Omit<FreebitCancelAccountRequest, "authKey"> = {
|
const request: Omit<FreebitCancelAccountRequest, "authKey"> = {
|
||||||
kind: "MVNO",
|
kind: "MVNO",
|
||||||
account,
|
account,
|
||||||
runDate,
|
...(runDate !== undefined && { runDate }),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, {
|
this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, {
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export class FreebitClientService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
const isProd = process.env["NODE_ENV"] === "production";
|
||||||
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
|
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
|
||||||
this.logger.error("Freebit API HTTP error", {
|
this.logger.error("Freebit API HTTP error", {
|
||||||
url,
|
url,
|
||||||
@ -78,7 +78,7 @@ export class FreebitClientService {
|
|||||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||||
|
|
||||||
if (resultCode && resultCode !== "100") {
|
if (resultCode && resultCode !== "100") {
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
const isProd = process.env["NODE_ENV"] === "production";
|
||||||
this.logger.warn("Freebit API returned error response", {
|
this.logger.warn("Freebit API returned error response", {
|
||||||
url,
|
url,
|
||||||
resultCode,
|
resultCode,
|
||||||
@ -166,7 +166,7 @@ export class FreebitClientService {
|
|||||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||||
|
|
||||||
if (resultCode && resultCode !== "100") {
|
if (resultCode && resultCode !== "100") {
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
const isProd = process.env["NODE_ENV"] === "production";
|
||||||
this.logger.error("Freebit API returned error result code", {
|
this.logger.error("Freebit API returned error result code", {
|
||||||
url,
|
url,
|
||||||
resultCode,
|
resultCode,
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
* Custom error class for Freebit API errors
|
* Custom error class for Freebit API errors
|
||||||
*/
|
*/
|
||||||
export class FreebitError extends Error {
|
export class FreebitError extends Error {
|
||||||
public readonly resultCode?: string | number;
|
public readonly resultCode?: string | number | undefined;
|
||||||
public readonly statusCode?: string | number;
|
public readonly statusCode?: string | number | undefined;
|
||||||
public readonly statusMessage?: string;
|
public readonly statusMessage?: string | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
@ -86,7 +86,7 @@ export class FreebitEsimService {
|
|||||||
account,
|
account,
|
||||||
eid: newEid,
|
eid: newEid,
|
||||||
addKind: "R",
|
addKind: "R",
|
||||||
planCode: options.planCode,
|
...(options.planCode !== undefined && { planCode: options.planCode }),
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
|
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
|
||||||
|
|||||||
@ -187,11 +187,12 @@ export class FreebitMapperService {
|
|||||||
throw new Error("No traffic data in response");
|
throw new Error("No traffic data in response");
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
const todayUsageKb = Number.parseInt(response.traffic.today, 10) || 0;
|
||||||
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
|
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
|
||||||
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
date:
|
||||||
usageKb: parseInt(usage, 10) || 0,
|
new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0] ?? "",
|
||||||
usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100,
|
usageKb: Number.parseInt(usage, 10) || 0,
|
||||||
|
usageMb: Math.round(((Number.parseInt(usage, 10) || 0) / 1024) * 100) / 100,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -216,8 +217,8 @@ export class FreebitMapperService {
|
|||||||
totalAdditions: Number(response.total) || 0,
|
totalAdditions: Number(response.total) || 0,
|
||||||
additionCount: Number(response.count) || 0,
|
additionCount: Number(response.count) || 0,
|
||||||
history: response.quotaHistory.map(item => ({
|
history: response.quotaHistory.map(item => ({
|
||||||
quotaKb: parseInt(item.quota, 10),
|
quotaKb: Number.parseInt(item.quota, 10),
|
||||||
quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100,
|
quotaMb: Math.round((Number.parseInt(item.quota, 10) / 1024) * 100) / 100,
|
||||||
addedDate: item.date,
|
addedDate: item.date,
|
||||||
expiryDate: item.expire,
|
expiryDate: item.expire,
|
||||||
campaignCode: item.quotaCode,
|
campaignCode: item.quotaCode,
|
||||||
@ -259,9 +260,9 @@ export class FreebitMapperService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const year = parseInt(dateString.substring(0, 4), 10);
|
const year = Number.parseInt(dateString.slice(0, 4), 10);
|
||||||
const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed
|
const month = Number.parseInt(dateString.slice(4, 6), 10) - 1; // Month is 0-indexed
|
||||||
const day = parseInt(dateString.substring(6, 8), 10);
|
const day = Number.parseInt(dateString.slice(6, 8), 10);
|
||||||
|
|
||||||
return new Date(year, month, day);
|
return new Date(year, month, day);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -111,7 +111,7 @@ export class FreebitOperationsService {
|
|||||||
account: string,
|
account: string,
|
||||||
newPlanCode: string,
|
newPlanCode: string,
|
||||||
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
): Promise<{ ipv4?: string | undefined; ipv6?: string | undefined }> {
|
||||||
const normalizedAccount = this.normalizeAccount(account);
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
return this.planService.changeSimPlan(normalizedAccount, newPlanCode, options);
|
return this.planService.changeSimPlan(normalizedAccount, newPlanCode, options);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export class FreebitPlanService {
|
|||||||
account: string,
|
account: string,
|
||||||
newPlanCode: string,
|
newPlanCode: string,
|
||||||
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
): Promise<{ ipv4?: string | undefined; ipv6?: string | undefined }> {
|
||||||
try {
|
try {
|
||||||
return await this.rateLimiter.executeWithSpacing(account, "plan", async () => {
|
return await this.rateLimiter.executeWithSpacing(account, "plan", async () => {
|
||||||
// First, get current SIM details to log for debugging
|
// First, get current SIM details to log for debugging
|
||||||
@ -127,17 +127,17 @@ export class FreebitPlanService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
errorDetails.errorName = error.name;
|
errorDetails["errorName"] = error.name;
|
||||||
errorDetails.errorMessage = error.message;
|
errorDetails["errorMessage"] = error.message;
|
||||||
|
|
||||||
if ("resultCode" in error) {
|
if ("resultCode" in error) {
|
||||||
errorDetails.resultCode = error.resultCode;
|
errorDetails["resultCode"] = (error as Record<string, unknown>)["resultCode"];
|
||||||
}
|
}
|
||||||
if ("statusCode" in error) {
|
if ("statusCode" in error) {
|
||||||
errorDetails.statusCode = error.statusCode;
|
errorDetails["statusCode"] = (error as Record<string, unknown>)["statusCode"];
|
||||||
}
|
}
|
||||||
if ("statusMessage" in error) {
|
if ("statusMessage" in error) {
|
||||||
errorDetails.statusMessage = error.statusMessage;
|
errorDetails["statusMessage"] = (error as Record<string, unknown>)["statusMessage"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -155,7 +155,7 @@ export class FreebitRateLimiterService {
|
|||||||
const parsed: OperationTimestamps = {};
|
const parsed: OperationTimestamps = {};
|
||||||
for (const [field, value] of Object.entries(raw)) {
|
for (const [field, value] of Object.entries(raw)) {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
if (!isNaN(num)) {
|
if (!Number.isNaN(num)) {
|
||||||
parsed[field as keyof OperationTimestamps] = num;
|
parsed[field as keyof OperationTimestamps] = num;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,8 +62,8 @@ export class FreebitUsageService {
|
|||||||
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
|
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
quota: quotaKb,
|
quota: quotaKb,
|
||||||
quotaCode: options.campaignCode,
|
...(options.campaignCode !== undefined && { quotaCode: options.campaignCode }),
|
||||||
expire: options.expiryDate,
|
...(options.expiryDate !== undefined && { expire: options.expiryDate }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduled = !!options.scheduledAt;
|
const scheduled = !!options.scheduledAt;
|
||||||
|
|||||||
@ -14,9 +14,9 @@ import type {
|
|||||||
} from "../interfaces/freebit.types.js";
|
} from "../interfaces/freebit.types.js";
|
||||||
|
|
||||||
export interface VoiceFeatures {
|
export interface VoiceFeatures {
|
||||||
voiceMailEnabled?: boolean;
|
voiceMailEnabled?: boolean | undefined;
|
||||||
callWaitingEnabled?: boolean;
|
callWaitingEnabled?: boolean | undefined;
|
||||||
internationalRoamingEnabled?: boolean;
|
internationalRoamingEnabled?: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -37,9 +37,9 @@ interface ConfigValidationError {
|
|||||||
* Japan Post API error response format
|
* Japan Post API error response format
|
||||||
*/
|
*/
|
||||||
interface JapanPostErrorResponse {
|
interface JapanPostErrorResponse {
|
||||||
request_id?: string;
|
request_id?: string | undefined;
|
||||||
error_code?: string;
|
error_code?: string | undefined;
|
||||||
message?: string;
|
message?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
type SalesforceOrderFieldMap,
|
type SalesforceOrderFieldMap,
|
||||||
} from "@customer-portal/domain/orders/providers";
|
} from "@customer-portal/domain/orders/providers";
|
||||||
|
|
||||||
const unique = (values: string[]): string[] => Array.from(new Set(values));
|
const unique = (values: string[]): string[] => [...new Set(values)];
|
||||||
|
|
||||||
const SECTION_PREFIX: Record<keyof SalesforceOrderFieldMap, string> = {
|
const SECTION_PREFIX: Record<keyof SalesforceOrderFieldMap, string> = {
|
||||||
order: "ORDER",
|
order: "ORDER",
|
||||||
|
|||||||
@ -80,7 +80,7 @@ export class AccountEventsSubscriber implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
private async handleAccountEvent(
|
private async handleAccountEvent(
|
||||||
channel: string,
|
channel: string,
|
||||||
subscription: { topicName?: string },
|
_subscription: { topicName?: string },
|
||||||
callbackType: string,
|
callbackType: string,
|
||||||
data: unknown
|
data: unknown
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@ -118,10 +118,10 @@ export class AccountEventsSubscriber implements OnModuleInit {
|
|||||||
if (this.accountNotificationHandler && (eligibilityStatus || verificationStatus)) {
|
if (this.accountNotificationHandler && (eligibilityStatus || verificationStatus)) {
|
||||||
void this.accountNotificationHandler.processAccountEvent({
|
void this.accountNotificationHandler.processAccountEvent({
|
||||||
accountId,
|
accountId,
|
||||||
eligibilityStatus,
|
eligibilityStatus: eligibilityStatus ?? null,
|
||||||
eligibilityValue: undefined,
|
eligibilityValue: null,
|
||||||
verificationStatus,
|
verificationStatus: verificationStatus ?? null,
|
||||||
verificationRejectionMessage: rejectionMessage,
|
verificationRejectionMessage: rejectionMessage ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export class CaseEventsSubscriber implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
private async handleCaseEvent(
|
private async handleCaseEvent(
|
||||||
channel: string,
|
channel: string,
|
||||||
subscription: { topicName?: string },
|
_subscription: { topicName?: string },
|
||||||
callbackType: string,
|
callbackType: string,
|
||||||
data: unknown
|
data: unknown
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@ -127,7 +127,7 @@ export class CatalogCdcSubscriber implements OnModuleInit {
|
|||||||
|
|
||||||
private async handlePricebookEvent(
|
private async handlePricebookEvent(
|
||||||
channel: string,
|
channel: string,
|
||||||
subscription: { topicName?: string },
|
_subscription: { topicName?: string },
|
||||||
callbackType: string,
|
callbackType: string,
|
||||||
data: unknown
|
data: unknown
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@ -111,7 +111,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
|
|||||||
|
|
||||||
private async handleOrderEvent(
|
private async handleOrderEvent(
|
||||||
channel: string,
|
channel: string,
|
||||||
subscription: { topicName?: string },
|
_subscription: { topicName?: string },
|
||||||
callbackType: string,
|
callbackType: string,
|
||||||
data: unknown
|
data: unknown
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@ -141,7 +141,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
|
|||||||
this.logger.debug("Order CDC: only internal field changes; skipping cache invalidation", {
|
this.logger.debug("Order CDC: only internal field changes; skipping cache invalidation", {
|
||||||
channel,
|
channel,
|
||||||
orderId,
|
orderId,
|
||||||
changedFields: Array.from(changedFields),
|
changedFields: [...changedFields],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -150,7 +150,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
|
|||||||
channel,
|
channel,
|
||||||
orderId,
|
orderId,
|
||||||
accountId,
|
accountId,
|
||||||
changedFields: Array.from(changedFields),
|
changedFields: [...changedFields],
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.invalidateOrderCaches(orderId, accountId);
|
await this.invalidateOrderCaches(orderId, accountId);
|
||||||
@ -222,7 +222,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
|
|||||||
|
|
||||||
private async handleOrderItemEvent(
|
private async handleOrderItemEvent(
|
||||||
channel: string,
|
channel: string,
|
||||||
subscription: { topicName?: string },
|
_subscription: { topicName?: string },
|
||||||
callbackType: string,
|
callbackType: string,
|
||||||
data: unknown
|
data: unknown
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@ -245,7 +245,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
|
|||||||
this.logger.debug("OrderItem CDC: only internal field changes; skipping", {
|
this.logger.debug("OrderItem CDC: only internal field changes; skipping", {
|
||||||
channel,
|
channel,
|
||||||
orderId,
|
orderId,
|
||||||
changedFields: Array.from(changedFields),
|
changedFields: [...changedFields],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -253,7 +253,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
|
|||||||
this.logger.log("OrderItem CDC: invalidating order cache", {
|
this.logger.log("OrderItem CDC: invalidating order cache", {
|
||||||
channel,
|
channel,
|
||||||
orderId,
|
orderId,
|
||||||
changedFields: Array.from(changedFields),
|
changedFields: [...changedFields],
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.invalidateOrderCaches(orderId, accountId);
|
await this.invalidateOrderCaches(orderId, accountId);
|
||||||
@ -291,7 +291,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerFacingChanges = Array.from(changedFields).filter(
|
const customerFacingChanges = [...changedFields].filter(
|
||||||
field => !INTERNAL_ORDER_FIELDS.has(field)
|
field => !INTERNAL_ORDER_FIELDS.has(field)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -303,7 +303,7 @@ export class OrderCdcSubscriber implements OnModuleInit {
|
|||||||
return true; // Safe default
|
return true; // Safe default
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerFacingChanges = Array.from(changedFields).filter(
|
const customerFacingChanges = [...changedFields].filter(
|
||||||
field => !INTERNAL_ORDER_ITEM_FIELDS.has(field)
|
field => !INTERNAL_ORDER_ITEM_FIELDS.has(field)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -98,8 +98,8 @@ export function extractChangedFields(payload: Record<string, unknown> | undefine
|
|||||||
|
|
||||||
// CDC provides changed fields in different formats depending on API version
|
// CDC provides changed fields in different formats depending on API version
|
||||||
const changedFieldsArray =
|
const changedFieldsArray =
|
||||||
(payload.changedFields as string[] | undefined) ||
|
(payload["changedFields"] as string[] | undefined) ||
|
||||||
(payload.changeOrigin as { changedFields?: string[] })?.changedFields ||
|
(payload["changeOrigin"] as { changedFields?: string[] } | undefined)?.changedFields ||
|
||||||
[];
|
[];
|
||||||
|
|
||||||
return new Set([
|
return new Set([
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export class SalesforceService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const nodeEnv =
|
const nodeEnv =
|
||||||
this.configService.get<string>("NODE_ENV") || process.env.NODE_ENV || "development";
|
this.configService.get<string>("NODE_ENV") || process.env["NODE_ENV"] || "development";
|
||||||
const isProd = nodeEnv === "production";
|
const isProd = nodeEnv === "production";
|
||||||
if (isProd) {
|
if (isProd) {
|
||||||
this.logger.error("Failed to initialize Salesforce connection");
|
this.logger.error("Failed to initialize Salesforce connection");
|
||||||
@ -63,15 +63,19 @@ export class SalesforceService implements OnModuleInit {
|
|||||||
return this.accountService.findByCustomerNumber(customerNumber);
|
return this.accountService.findByCustomerNumber(customerNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAccountWithDetailsByCustomerNumber(
|
async findAccountWithDetailsByCustomerNumber(customerNumber: string): Promise<{
|
||||||
customerNumber: string
|
id: string;
|
||||||
): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> {
|
WH_Account__c?: string | null | undefined;
|
||||||
|
Name?: string | null | undefined;
|
||||||
|
} | null> {
|
||||||
return this.accountService.findWithDetailsByCustomerNumber(customerNumber);
|
return this.accountService.findWithDetailsByCustomerNumber(customerNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccountDetails(
|
async getAccountDetails(accountId: string): Promise<{
|
||||||
accountId: string
|
id: string;
|
||||||
): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> {
|
WH_Account__c?: string | null | undefined;
|
||||||
|
Name?: string | null | undefined;
|
||||||
|
} | null> {
|
||||||
return this.accountService.getAccountDetails(accountId);
|
return this.accountService.getAccountDetails(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -82,7 +82,7 @@ export class OpportunityResolutionService {
|
|||||||
async resolveForOrderPlacement(params: {
|
async resolveForOrderPlacement(params: {
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
orderType: OrderTypeValue;
|
orderType: OrderTypeValue;
|
||||||
existingOpportunityId?: string;
|
existingOpportunityId?: string | undefined;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
if (!params.accountId) return null;
|
if (!params.accountId) return null;
|
||||||
|
|
||||||
|
|||||||
@ -110,9 +110,9 @@ export class OpportunityMutationService {
|
|||||||
|
|
||||||
if (error && typeof error === "object") {
|
if (error && typeof error === "object") {
|
||||||
const err = error as Record<string, unknown>;
|
const err = error as Record<string, unknown>;
|
||||||
if (err.errorCode) errorDetails.errorCode = err.errorCode;
|
if (err["errorCode"]) errorDetails["errorCode"] = err["errorCode"];
|
||||||
if (err.fields) errorDetails.fields = err.fields;
|
if (err["fields"]) errorDetails["fields"] = err["fields"];
|
||||||
if (err.message) errorDetails.rawMessage = err.message;
|
if (err["message"]) errorDetails["rawMessage"] = err["message"];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(errorDetails, "Failed to create Opportunity");
|
this.logger.error(errorDetails, "Failed to create Opportunity");
|
||||||
@ -246,7 +246,7 @@ export class OpportunityMutationService {
|
|||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
private calculateCloseDate(
|
private calculateCloseDate(
|
||||||
productType: OpportunityProductTypeValue,
|
_productType: OpportunityProductTypeValue,
|
||||||
stage: OpportunityStageValue
|
stage: OpportunityStageValue
|
||||||
): string {
|
): string {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|||||||
@ -39,15 +39,15 @@ export interface InternetCancellationStatusResult {
|
|||||||
stage: OpportunityStageValue;
|
stage: OpportunityStageValue;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
scheduledEndDate?: string;
|
scheduledEndDate?: string | undefined;
|
||||||
rentalReturnStatus?: LineReturnStatusValue;
|
rentalReturnStatus?: LineReturnStatusValue | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimCancellationStatusResult {
|
export interface SimCancellationStatusResult {
|
||||||
stage: OpportunityStageValue;
|
stage: OpportunityStageValue;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
scheduledEndDate?: string;
|
scheduledEndDate?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -125,8 +125,8 @@ export class OpportunityQueryService {
|
|||||||
|
|
||||||
if (error && typeof error === "object") {
|
if (error && typeof error === "object") {
|
||||||
const err = error as Record<string, unknown>;
|
const err = error as Record<string, unknown>;
|
||||||
if (err.errorCode) errorDetails.errorCode = err.errorCode;
|
if (err["errorCode"]) errorDetails["errorCode"] = err["errorCode"];
|
||||||
if (err.message) errorDetails.rawMessage = err.message;
|
if (err["message"]) errorDetails["rawMessage"] = err["message"];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(errorDetails, "Failed to find open Opportunity");
|
this.logger.error(errorDetails, "Failed to find open Opportunity");
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export class SalesforceAccountService {
|
|||||||
private readonly portalSourceField: string;
|
private readonly portalSourceField: string;
|
||||||
private readonly portalLastSignedInField: string;
|
private readonly portalLastSignedInField: string;
|
||||||
private readonly whmcsAccountField: string;
|
private readonly whmcsAccountField: string;
|
||||||
private readonly personAccountRecordTypeId?: string;
|
private readonly personAccountRecordTypeId?: string | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find Salesforce account by customer number (SF_Account_No__c field)
|
* Find Salesforce account by customer number (SF_Account_No__c field)
|
||||||
@ -65,8 +65,8 @@ export class SalesforceAccountService {
|
|||||||
|
|
||||||
async findWithDetailsByCustomerNumber(customerNumber: string): Promise<{
|
async findWithDetailsByCustomerNumber(customerNumber: string): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
Name?: string | null;
|
Name?: string | null | undefined;
|
||||||
WH_Account__c?: string | null;
|
WH_Account__c?: string | null | undefined;
|
||||||
} | null> {
|
} | null> {
|
||||||
const validCustomerNumber = customerNumberSchema.parse(customerNumber);
|
const validCustomerNumber = customerNumberSchema.parse(customerNumber);
|
||||||
|
|
||||||
@ -98,9 +98,11 @@ export class SalesforceAccountService {
|
|||||||
* Get account details including WH_Account__c field
|
* Get account details including WH_Account__c field
|
||||||
* Used in signup workflow to check if account is already linked to WHMCS
|
* Used in signup workflow to check if account is already linked to WHMCS
|
||||||
*/
|
*/
|
||||||
async getAccountDetails(
|
async getAccountDetails(accountId: string): Promise<{
|
||||||
accountId: string
|
id: string;
|
||||||
): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> {
|
WH_Account__c?: string | null | undefined;
|
||||||
|
Name?: string | null | undefined;
|
||||||
|
} | null> {
|
||||||
const validAccountId = salesforceIdSchema.parse(accountId);
|
const validAccountId = salesforceIdSchema.parse(accountId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -196,9 +198,9 @@ export class SalesforceAccountService {
|
|||||||
|
|
||||||
this.logger.debug("Person Account creation payload", {
|
this.logger.debug("Person Account creation payload", {
|
||||||
recordTypeId: personAccountRecordTypeId,
|
recordTypeId: personAccountRecordTypeId,
|
||||||
hasFirstName: Boolean(accountPayload.FirstName),
|
hasFirstName: Boolean(accountPayload["FirstName"]),
|
||||||
hasLastName: Boolean(accountPayload.LastName),
|
hasLastName: Boolean(accountPayload["LastName"]),
|
||||||
hasPersonEmail: Boolean(accountPayload.PersonEmail),
|
hasPersonEmail: Boolean(accountPayload["PersonEmail"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -249,18 +251,18 @@ export class SalesforceAccountService {
|
|||||||
if (error && typeof error === "object") {
|
if (error && typeof error === "object") {
|
||||||
const err = error as Record<string, unknown>;
|
const err = error as Record<string, unknown>;
|
||||||
// jsforce errors often have these properties
|
// jsforce errors often have these properties
|
||||||
if (err.errorCode) errorDetails.errorCode = err.errorCode;
|
if (err["errorCode"]) errorDetails["errorCode"] = err["errorCode"];
|
||||||
if (err.fields) errorDetails.fields = err.fields;
|
if (err["fields"]) errorDetails["fields"] = err["fields"];
|
||||||
if (err.message) errorDetails.rawMessage = err.message;
|
if (err["message"]) errorDetails["rawMessage"] = err["message"];
|
||||||
// Check for nested error objects
|
// Check for nested error objects
|
||||||
if (err.error) errorDetails.nestedError = err.error;
|
if (err["error"]) errorDetails["nestedError"] = err["error"];
|
||||||
// Log all enumerable properties in development
|
// Log all enumerable properties in development
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env["NODE_ENV"] !== "production") {
|
||||||
errorDetails.allProperties = Object.keys(err);
|
errorDetails["allProperties"] = Object.keys(err);
|
||||||
try {
|
try {
|
||||||
errorDetails.fullError = JSON.stringify(error, null, 2);
|
errorDetails["fullError"] = JSON.stringify(error, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
errorDetails.fullError = errorMessage;
|
errorDetails["fullError"] = errorMessage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -298,10 +300,14 @@ export class SalesforceAccountService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordTypeId = recordTypeQuery.records[0].Id;
|
const record = recordTypeQuery.records[0];
|
||||||
|
if (!record) {
|
||||||
|
throw new Error("Person Account RecordType record not found");
|
||||||
|
}
|
||||||
|
const recordTypeId = record.Id;
|
||||||
this.logger.debug("Found Person Account RecordType", {
|
this.logger.debug("Found Person Account RecordType", {
|
||||||
recordTypeId,
|
recordTypeId,
|
||||||
name: recordTypeQuery.records[0].Name,
|
name: record.Name,
|
||||||
});
|
});
|
||||||
return recordTypeId;
|
return recordTypeId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -19,19 +19,15 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
|||||||
import { CASE_FIELDS } from "../constants/field-maps.js";
|
import { CASE_FIELDS } from "../constants/field-maps.js";
|
||||||
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
|
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
|
||||||
import type { SupportCase, CaseMessageList } from "@customer-portal/domain/support";
|
import type { SupportCase, CaseMessageList } from "@customer-portal/domain/support";
|
||||||
import type {
|
|
||||||
SalesforceCaseRecord,
|
|
||||||
SalesforceEmailMessage,
|
|
||||||
SalesforceCaseComment,
|
|
||||||
} from "@customer-portal/domain/support/providers";
|
|
||||||
import {
|
import {
|
||||||
|
type SalesforceCaseRecord,
|
||||||
|
type SalesforceEmailMessage,
|
||||||
|
type SalesforceCaseComment,
|
||||||
|
type SalesforceCaseOrigin,
|
||||||
SALESFORCE_CASE_ORIGIN,
|
SALESFORCE_CASE_ORIGIN,
|
||||||
SALESFORCE_CASE_STATUS,
|
SALESFORCE_CASE_STATUS,
|
||||||
SALESFORCE_CASE_PRIORITY,
|
SALESFORCE_CASE_PRIORITY,
|
||||||
toSalesforcePriority,
|
toSalesforcePriority,
|
||||||
type SalesforceCaseOrigin,
|
|
||||||
} from "@customer-portal/domain/support/providers";
|
|
||||||
import {
|
|
||||||
buildCaseByIdQuery,
|
buildCaseByIdQuery,
|
||||||
buildCaseSelectFields,
|
buildCaseSelectFields,
|
||||||
buildCasesForAccountQuery,
|
buildCasesForAccountQuery,
|
||||||
@ -65,11 +61,11 @@ export interface CreateCaseParams {
|
|||||||
/** Case origin - determines visibility and routing */
|
/** Case origin - determines visibility and routing */
|
||||||
origin: SalesforceCaseOrigin;
|
origin: SalesforceCaseOrigin;
|
||||||
/** Priority (defaults to Medium) */
|
/** Priority (defaults to Medium) */
|
||||||
priority?: string;
|
priority?: string | undefined;
|
||||||
/** Optional Salesforce Contact ID */
|
/** Optional Salesforce Contact ID */
|
||||||
contactId?: string;
|
contactId?: string | undefined;
|
||||||
/** Optional Opportunity ID for workflow cases */
|
/** Optional Opportunity ID for workflow cases */
|
||||||
opportunityId?: string;
|
opportunityId?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -80,9 +76,9 @@ export interface CreateWebCaseParams {
|
|||||||
description: string;
|
description: string;
|
||||||
suppliedEmail: string;
|
suppliedEmail: string;
|
||||||
suppliedName: string;
|
suppliedName: string;
|
||||||
suppliedPhone?: string;
|
suppliedPhone?: string | undefined;
|
||||||
origin?: string;
|
origin?: string | undefined;
|
||||||
priority?: string;
|
priority?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@ -60,8 +60,8 @@ function normalizePrivateKeyInput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SalesforceSObjectApi {
|
export interface SalesforceSObjectApi {
|
||||||
create: (data: Record<string, unknown>) => Promise<{ id?: string }>;
|
create: (data: Record<string, unknown>) => Promise<{ id?: string | undefined }>;
|
||||||
update?: (data: Record<string, unknown> & { Id: string }) => Promise<unknown>;
|
update?: ((data: Record<string, unknown> & { Id: string }) => Promise<unknown>) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -114,7 +114,7 @@ export class SalesforceConnection {
|
|||||||
|
|
||||||
private async performConnect(): Promise<void> {
|
private async performConnect(): Promise<void> {
|
||||||
const nodeEnv =
|
const nodeEnv =
|
||||||
this.configService.get<string>("NODE_ENV") || process.env.NODE_ENV || "development";
|
this.configService.get<string>("NODE_ENV") || process.env["NODE_ENV"] || "development";
|
||||||
const isProd = nodeEnv === "production";
|
const isProd = nodeEnv === "production";
|
||||||
const authTimeout = this.configService.get<number>("SF_AUTH_TIMEOUT_MS", 30000);
|
const authTimeout = this.configService.get<number>("SF_AUTH_TIMEOUT_MS", 30000);
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|||||||
@ -21,11 +21,14 @@ import {
|
|||||||
type OpportunityStageValue,
|
type OpportunityStageValue,
|
||||||
type OpportunityProductTypeValue,
|
type OpportunityProductTypeValue,
|
||||||
type CancellationOpportunityData,
|
type CancellationOpportunityData,
|
||||||
type LineReturnStatusValue,
|
|
||||||
type CreateOpportunityRequest,
|
type CreateOpportunityRequest,
|
||||||
type OpportunityRecord,
|
type OpportunityRecord,
|
||||||
} from "@customer-portal/domain/opportunity";
|
} from "@customer-portal/domain/opportunity";
|
||||||
import { OpportunityQueryService } from "./opportunity/opportunity-query.service.js";
|
import {
|
||||||
|
OpportunityQueryService,
|
||||||
|
type InternetCancellationStatusResult,
|
||||||
|
type SimCancellationStatusResult,
|
||||||
|
} from "./opportunity/opportunity-query.service.js";
|
||||||
import { OpportunityCancellationService } from "./opportunity/opportunity-cancellation.service.js";
|
import { OpportunityCancellationService } from "./opportunity/opportunity-cancellation.service.js";
|
||||||
import { OpportunityMutationService } from "./opportunity/opportunity-mutation.service.js";
|
import { OpportunityMutationService } from "./opportunity/opportunity-mutation.service.js";
|
||||||
import type {
|
import type {
|
||||||
@ -147,63 +150,45 @@ export class SalesforceOpportunityService {
|
|||||||
/**
|
/**
|
||||||
* Get Internet cancellation status by WHMCS Service ID
|
* Get Internet cancellation status by WHMCS Service ID
|
||||||
*/
|
*/
|
||||||
async getInternetCancellationStatus(whmcsServiceId: number): Promise<{
|
async getInternetCancellationStatus(
|
||||||
stage: OpportunityStageValue;
|
whmcsServiceId: number
|
||||||
isPending: boolean;
|
): Promise<InternetCancellationStatusResult | null> {
|
||||||
isComplete: boolean;
|
|
||||||
scheduledEndDate?: string;
|
|
||||||
rentalReturnStatus?: LineReturnStatusValue;
|
|
||||||
} | null> {
|
|
||||||
return this.queryService.getInternetCancellationStatus(whmcsServiceId);
|
return this.queryService.getInternetCancellationStatus(whmcsServiceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Internet cancellation status by Opportunity ID
|
* Get Internet cancellation status by Opportunity ID
|
||||||
*/
|
*/
|
||||||
async getInternetCancellationStatusByOpportunityId(opportunityId: string): Promise<{
|
async getInternetCancellationStatusByOpportunityId(
|
||||||
stage: OpportunityStageValue;
|
opportunityId: string
|
||||||
isPending: boolean;
|
): Promise<InternetCancellationStatusResult | null> {
|
||||||
isComplete: boolean;
|
|
||||||
scheduledEndDate?: string;
|
|
||||||
rentalReturnStatus?: LineReturnStatusValue;
|
|
||||||
} | null> {
|
|
||||||
return this.queryService.getInternetCancellationStatusByOpportunityId(opportunityId);
|
return this.queryService.getInternetCancellationStatusByOpportunityId(opportunityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get SIM cancellation status by WHMCS Service ID
|
* Get SIM cancellation status by WHMCS Service ID
|
||||||
*/
|
*/
|
||||||
async getSimCancellationStatus(whmcsServiceId: number): Promise<{
|
async getSimCancellationStatus(
|
||||||
stage: OpportunityStageValue;
|
whmcsServiceId: number
|
||||||
isPending: boolean;
|
): Promise<SimCancellationStatusResult | null> {
|
||||||
isComplete: boolean;
|
|
||||||
scheduledEndDate?: string;
|
|
||||||
} | null> {
|
|
||||||
return this.queryService.getSimCancellationStatus(whmcsServiceId);
|
return this.queryService.getSimCancellationStatus(whmcsServiceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get SIM cancellation status by Opportunity ID
|
* Get SIM cancellation status by Opportunity ID
|
||||||
*/
|
*/
|
||||||
async getSimCancellationStatusByOpportunityId(opportunityId: string): Promise<{
|
async getSimCancellationStatusByOpportunityId(
|
||||||
stage: OpportunityStageValue;
|
opportunityId: string
|
||||||
isPending: boolean;
|
): Promise<SimCancellationStatusResult | null> {
|
||||||
isComplete: boolean;
|
|
||||||
scheduledEndDate?: string;
|
|
||||||
} | null> {
|
|
||||||
return this.queryService.getSimCancellationStatusByOpportunityId(opportunityId);
|
return this.queryService.getSimCancellationStatusByOpportunityId(opportunityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Use getInternetCancellationStatus or getSimCancellationStatus
|
* @deprecated Use getInternetCancellationStatus or getSimCancellationStatus
|
||||||
*/
|
*/
|
||||||
async getCancellationStatus(whmcsServiceId: number): Promise<{
|
async getCancellationStatus(
|
||||||
stage: OpportunityStageValue;
|
whmcsServiceId: number
|
||||||
isPending: boolean;
|
): Promise<InternetCancellationStatusResult | null> {
|
||||||
isComplete: boolean;
|
|
||||||
scheduledEndDate?: string;
|
|
||||||
rentalReturnStatus?: LineReturnStatusValue;
|
|
||||||
} | null> {
|
|
||||||
return this.queryService.getInternetCancellationStatus(whmcsServiceId);
|
return this.queryService.getInternetCancellationStatus(whmcsServiceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export interface SftpConfig {
|
|||||||
* The value is compared against ssh2's `hostHash: "sha256"` output.
|
* The value is compared against ssh2's `hostHash: "sha256"` output.
|
||||||
* If you paste an OpenSSH-style fingerprint like "SHA256:xxxx", the "SHA256:" prefix is ignored.
|
* If you paste an OpenSSH-style fingerprint like "SHA256:xxxx", the "SHA256:" prefix is ignored.
|
||||||
*/
|
*/
|
||||||
hostKeySha256?: string;
|
hostKeySha256?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@ -248,14 +248,14 @@ export class WhmcsCacheService {
|
|||||||
async invalidateUserCache(userId: string): Promise<void> {
|
async invalidateUserCache(userId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
`${this.cacheConfigs.invoices.prefix}:${userId}:*`,
|
`${this.cacheConfigs["invoices"]?.prefix}:${userId}:*`,
|
||||||
`${this.cacheConfigs.invoice.prefix}:${userId}:*`,
|
`${this.cacheConfigs["invoice"]?.prefix}:${userId}:*`,
|
||||||
`${this.cacheConfigs.subscriptions.prefix}:${userId}:*`,
|
`${this.cacheConfigs["subscriptions"]?.prefix}:${userId}:*`,
|
||||||
`${this.cacheConfigs.subscription.prefix}:${userId}:*`,
|
`${this.cacheConfigs["subscription"]?.prefix}:${userId}:*`,
|
||||||
`${this.cacheConfigs.subscriptionInvoicesAll.prefix}:${userId}:*`,
|
`${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`,
|
||||||
];
|
];
|
||||||
|
|
||||||
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern)));
|
await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern)));
|
||||||
|
|
||||||
this.logger.log(`Invalidated all cache for user ${userId}`);
|
this.logger.log(`Invalidated all cache for user ${userId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -292,7 +292,7 @@ export class WhmcsCacheService {
|
|||||||
.filter(config => config.tags.includes(tag))
|
.filter(config => config.tags.includes(tag))
|
||||||
.map(config => `${config.prefix}:*`);
|
.map(config => `${config.prefix}:*`);
|
||||||
|
|
||||||
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern)));
|
await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern)));
|
||||||
|
|
||||||
this.logger.log(`Invalidated cache by tag: ${tag}`);
|
this.logger.log(`Invalidated cache by tag: ${tag}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -308,8 +308,8 @@ export class WhmcsCacheService {
|
|||||||
async invalidateInvoice(userId: string, invoiceId: number): Promise<void> {
|
async invalidateInvoice(userId: string, invoiceId: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const specificKey = this.buildInvoiceKey(userId, invoiceId);
|
const specificKey = this.buildInvoiceKey(userId, invoiceId);
|
||||||
const listPattern = `${this.cacheConfigs.invoices.prefix}:${userId}:*`;
|
const listPattern = `${this.cacheConfigs["invoices"]?.prefix}:${userId}:*`;
|
||||||
const subscriptionInvoicesPattern = `${this.cacheConfigs.subscriptionInvoicesAll.prefix}:${userId}:*`;
|
const subscriptionInvoicesPattern = `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`;
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.cacheService.del(specificKey),
|
this.cacheService.del(specificKey),
|
||||||
@ -432,6 +432,10 @@ export class WhmcsCacheService {
|
|||||||
private async set<T>(key: string, data: T, configKey: string): Promise<void> {
|
private async set<T>(key: string, data: T, configKey: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const config = this.cacheConfigs[configKey];
|
const config = this.cacheConfigs[configKey];
|
||||||
|
if (!config) {
|
||||||
|
this.logger.warn(`Cache config not found for key ${configKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.cacheService.set(key, data, config.ttl);
|
await this.cacheService.set(key, data, config.ttl);
|
||||||
this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`);
|
this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -443,28 +447,28 @@ export class WhmcsCacheService {
|
|||||||
* Build cache key for invoices list
|
* Build cache key for invoices list
|
||||||
*/
|
*/
|
||||||
private buildInvoicesKey(userId: string, page: number, limit: number, status?: string): string {
|
private buildInvoicesKey(userId: string, page: number, limit: number, status?: string): string {
|
||||||
return `${this.cacheConfigs.invoices.prefix}:${userId}:${page}:${limit}:${status || "all"}`;
|
return `${this.cacheConfigs["invoices"]?.prefix}:${userId}:${page}:${limit}:${status || "all"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build cache key for individual invoice
|
* Build cache key for individual invoice
|
||||||
*/
|
*/
|
||||||
private buildInvoiceKey(userId: string, invoiceId: number): string {
|
private buildInvoiceKey(userId: string, invoiceId: number): string {
|
||||||
return `${this.cacheConfigs.invoice.prefix}:${userId}:${invoiceId}`;
|
return `${this.cacheConfigs["invoice"]?.prefix}:${userId}:${invoiceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build cache key for subscriptions list
|
* Build cache key for subscriptions list
|
||||||
*/
|
*/
|
||||||
private buildSubscriptionsKey(userId: string): string {
|
private buildSubscriptionsKey(userId: string): string {
|
||||||
return `${this.cacheConfigs.subscriptions.prefix}:${userId}`;
|
return `${this.cacheConfigs["subscriptions"]?.prefix}:${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build cache key for individual subscription
|
* Build cache key for individual subscription
|
||||||
*/
|
*/
|
||||||
private buildSubscriptionKey(userId: string, subscriptionId: number): string {
|
private buildSubscriptionKey(userId: string, subscriptionId: number): string {
|
||||||
return `${this.cacheConfigs.subscription.prefix}:${userId}:${subscriptionId}`;
|
return `${this.cacheConfigs["subscription"]?.prefix}:${userId}:${subscriptionId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -476,35 +480,35 @@ export class WhmcsCacheService {
|
|||||||
page: number,
|
page: number,
|
||||||
limit: number
|
limit: number
|
||||||
): string {
|
): string {
|
||||||
return `${this.cacheConfigs.subscriptionInvoices.prefix}:${userId}:${subscriptionId}:${page}:${limit}`;
|
return `${this.cacheConfigs["subscriptionInvoices"]?.prefix}:${userId}:${subscriptionId}:${page}:${limit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build cache key for full subscription invoices list
|
* Build cache key for full subscription invoices list
|
||||||
*/
|
*/
|
||||||
private buildSubscriptionInvoicesAllKey(userId: string, subscriptionId: number): string {
|
private buildSubscriptionInvoicesAllKey(userId: string, subscriptionId: number): string {
|
||||||
return `${this.cacheConfigs.subscriptionInvoicesAll.prefix}:${userId}:${subscriptionId}`;
|
return `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:${subscriptionId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build cache key for client data
|
* Build cache key for client data
|
||||||
*/
|
*/
|
||||||
private buildClientKey(clientId: number): string {
|
private buildClientKey(clientId: number): string {
|
||||||
return `${this.cacheConfigs.client.prefix}:${clientId}`;
|
return `${this.cacheConfigs["client"]?.prefix}:${clientId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build cache key for client email mapping
|
* Build cache key for client email mapping
|
||||||
*/
|
*/
|
||||||
private buildClientEmailKey(email: string): string {
|
private buildClientEmailKey(email: string): string {
|
||||||
return `${this.cacheConfigs.clientEmail.prefix}:${email.toLowerCase()}`;
|
return `${this.cacheConfigs["clientEmail"]?.prefix}:${email.toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build cache key for payment methods
|
* Build cache key for payment methods
|
||||||
*/
|
*/
|
||||||
private buildPaymentMethodsKey(userId: string): string {
|
private buildPaymentMethodsKey(userId: string): string {
|
||||||
return `${this.cacheConfigs.paymentMethods.prefix}:${userId}`;
|
return `${this.cacheConfigs["paymentMethods"]?.prefix}:${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -528,7 +532,7 @@ export class WhmcsCacheService {
|
|||||||
async clearAllCache(): Promise<void> {
|
async clearAllCache(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const patterns = Object.values(this.cacheConfigs).map(config => `${config.prefix}:*`);
|
const patterns = Object.values(this.cacheConfigs).map(config => `${config.prefix}:*`);
|
||||||
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern)));
|
await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern)));
|
||||||
this.logger.warn("Cleared all WHMCS cache");
|
this.logger.warn("Cleared all WHMCS cache");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to clear all WHMCS cache", { error: extractErrorMessage(error) });
|
this.logger.error("Failed to clear all WHMCS cache", { error: extractErrorMessage(error) });
|
||||||
|
|||||||
@ -93,7 +93,7 @@ export class WhmcsConfigService {
|
|||||||
const value = this.configService.get<string>(key);
|
const value = this.configService.get<string>(key);
|
||||||
if (!value) return defaultValue;
|
if (!value) return defaultValue;
|
||||||
|
|
||||||
const parsed = parseInt(value, 10);
|
const parsed = Number.parseInt(value, 10);
|
||||||
return isNaN(parsed) ? defaultValue : parsed;
|
return Number.isNaN(parsed) ? defaultValue : parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,8 +26,8 @@ import type {
|
|||||||
WhmcsUpdateInvoiceResponse,
|
WhmcsUpdateInvoiceResponse,
|
||||||
WhmcsCapturePaymentResponse,
|
WhmcsCapturePaymentResponse,
|
||||||
} from "@customer-portal/domain/billing/providers";
|
} from "@customer-portal/domain/billing/providers";
|
||||||
import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments/providers";
|
|
||||||
import type {
|
import type {
|
||||||
|
WhmcsGetPayMethodsParams,
|
||||||
WhmcsPaymentMethodListResponse,
|
WhmcsPaymentMethodListResponse,
|
||||||
WhmcsPaymentGatewayListResponse,
|
WhmcsPaymentGatewayListResponse,
|
||||||
} from "@customer-portal/domain/payments/providers";
|
} from "@customer-portal/domain/payments/providers";
|
||||||
|
|||||||
@ -128,7 +128,7 @@ export class WhmcsHttpClientService {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Do NOT include response body in thrown error messages (could contain sensitive/PII and
|
// Do NOT include response body in thrown error messages (could contain sensitive/PII and
|
||||||
// would propagate into unified exception logs). If needed, emit a short snippet only in dev.
|
// would propagate into unified exception logs). If needed, emit a short snippet only in dev.
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env["NODE_ENV"] !== "production") {
|
||||||
const snippet = responseText?.slice(0, 300);
|
const snippet = responseText?.slice(0, 300);
|
||||||
if (snippet) {
|
if (snippet) {
|
||||||
this.logger.debug(`WHMCS non-OK response body snippet [${action}]`, {
|
this.logger.debug(`WHMCS non-OK response body snippet [${action}]`, {
|
||||||
@ -182,9 +182,9 @@ export class WhmcsHttpClientService {
|
|||||||
|
|
||||||
private appendFormParam(formData: URLSearchParams, key: string, value: unknown): void {
|
private appendFormParam(formData: URLSearchParams, key: string, value: unknown): void {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
value.forEach((entry, index) => {
|
for (const [index, entry] of value.entries()) {
|
||||||
this.appendFormParam(formData, `${key}[${index}]`, entry);
|
this.appendFormParam(formData, `${key}[${index}]`, entry);
|
||||||
});
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,11 +234,11 @@ export class WhmcsHttpClientService {
|
|||||||
try {
|
try {
|
||||||
parsedResponse = JSON.parse(responseText);
|
parsedResponse = JSON.parse(responseText);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
const isProd = process.env["NODE_ENV"] === "production";
|
||||||
this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, {
|
this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, {
|
||||||
...(isProd
|
...(isProd
|
||||||
? { responseTextLength: responseText.length }
|
? { responseTextLength: responseText.length }
|
||||||
: { responseText: responseText.substring(0, 500) }),
|
: { responseText: responseText.slice(0, 500) }),
|
||||||
parseError: extractErrorMessage(parseError),
|
parseError: extractErrorMessage(parseError),
|
||||||
params: redactForLogs(params),
|
params: redactForLogs(params),
|
||||||
});
|
});
|
||||||
@ -247,12 +247,12 @@ export class WhmcsHttpClientService {
|
|||||||
|
|
||||||
// Validate basic response structure
|
// Validate basic response structure
|
||||||
if (!this.isWhmcsResponse(parsedResponse)) {
|
if (!this.isWhmcsResponse(parsedResponse)) {
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
const isProd = process.env["NODE_ENV"] === "production";
|
||||||
this.logger.error(`WHMCS API returned invalid response structure [${action}]`, {
|
this.logger.error(`WHMCS API returned invalid response structure [${action}]`, {
|
||||||
responseType: typeof parsedResponse,
|
responseType: typeof parsedResponse,
|
||||||
...(isProd
|
...(isProd
|
||||||
? { responseTextLength: responseText.length }
|
? { responseTextLength: responseText.length }
|
||||||
: { responseText: responseText.substring(0, 500) }),
|
: { responseText: responseText.slice(0, 500) }),
|
||||||
params: redactForLogs(params),
|
params: redactForLogs(params),
|
||||||
});
|
});
|
||||||
throw new Error("Invalid response structure from WHMCS API");
|
throw new Error("Invalid response structure from WHMCS API");
|
||||||
@ -308,7 +308,7 @@ export class WhmcsHttpClientService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const record = value as Record<string, unknown>;
|
const record = value as Record<string, unknown>;
|
||||||
const rawResult = record.result;
|
const rawResult = record["result"];
|
||||||
return rawResult === "success" || rawResult === "error";
|
return rawResult === "success" || rawResult === "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,19 +2,19 @@ export interface WhmcsApiConfig {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
timeout?: number;
|
timeout?: number | undefined;
|
||||||
retryAttempts?: number;
|
retryAttempts?: number | undefined;
|
||||||
retryDelay?: number;
|
retryDelay?: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WhmcsRequestOptions {
|
export interface WhmcsRequestOptions {
|
||||||
timeout?: number;
|
timeout?: number | undefined;
|
||||||
retryAttempts?: number;
|
retryAttempts?: number | undefined;
|
||||||
retryDelay?: number;
|
retryDelay?: number | undefined;
|
||||||
/**
|
/**
|
||||||
* If true, the request will jump the queue and execute immediately
|
* If true, the request will jump the queue and execute immediately
|
||||||
*/
|
*/
|
||||||
highPriority?: boolean;
|
highPriority?: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WhmcsRetryConfig {
|
export interface WhmcsRetryConfig {
|
||||||
@ -29,6 +29,6 @@ export interface WhmcsConnectionStats {
|
|||||||
successfulRequests: number;
|
successfulRequests: number;
|
||||||
failedRequests: number;
|
failedRequests: number;
|
||||||
averageResponseTime: number;
|
averageResponseTime: number;
|
||||||
lastRequestTime?: Date;
|
lastRequestTime?: Date | undefined;
|
||||||
lastErrorTime?: Date;
|
lastErrorTime?: Date | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -114,13 +114,14 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const response = (await this.connectionService.getCurrencies()) as WhmcsCurrenciesResponse;
|
const response = (await this.connectionService.getCurrencies()) as WhmcsCurrenciesResponse;
|
||||||
|
|
||||||
// Check if response has currencies data (success case) or error fields
|
// Check if response has currencies data (success case) or error fields
|
||||||
if (response.result === "success" || (response.currencies && !response.error)) {
|
if (response.result === "success" || (response.currencies && !response["error"])) {
|
||||||
// Parse the WHMCS response format into currency objects
|
// Parse the WHMCS response format into currency objects
|
||||||
this.currencies = this.parseWhmcsCurrenciesResponse(response);
|
this.currencies = this.parseWhmcsCurrenciesResponse(response);
|
||||||
|
|
||||||
if (this.currencies.length > 0) {
|
if (this.currencies.length > 0) {
|
||||||
// Set first currency as default (WHMCS typically returns the primary currency first)
|
// Set first currency as default (WHMCS typically returns the primary currency first)
|
||||||
this.defaultCurrency = this.currencies[0];
|
const firstCurrency = this.currencies[0];
|
||||||
|
this.defaultCurrency = firstCurrency ?? null;
|
||||||
|
|
||||||
this.logger.log(`Loaded ${this.currencies.length} currencies from WHMCS`, {
|
this.logger.log(`Loaded ${this.currencies.length} currencies from WHMCS`, {
|
||||||
defaultCurrency: this.defaultCurrency?.code,
|
defaultCurrency: this.defaultCurrency?.code,
|
||||||
@ -134,13 +135,13 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
|
|||||||
} else {
|
} else {
|
||||||
this.logger.error("WHMCS GetCurrencies returned error", {
|
this.logger.error("WHMCS GetCurrencies returned error", {
|
||||||
result: response?.result,
|
result: response?.result,
|
||||||
message: response?.message,
|
message: response?.["message"],
|
||||||
error: response?.error,
|
error: response?.["error"],
|
||||||
errorcode: response?.errorcode,
|
errorcode: response?.["errorcode"],
|
||||||
fullResponse: JSON.stringify(response, null, 2),
|
fullResponse: JSON.stringify(response, null, 2),
|
||||||
});
|
});
|
||||||
throw new WhmcsOperationException(
|
throw new WhmcsOperationException(
|
||||||
`WHMCS GetCurrencies error: ${response?.message || response?.error || "Unknown error"}`,
|
`WHMCS GetCurrencies error: ${response?.["message"] || response?.["error"] || "Unknown error"}`,
|
||||||
{ operation: "getCurrencies" }
|
{ operation: "getCurrencies" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -171,7 +172,7 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
for (const currencyData of currencyArray) {
|
for (const currencyData of currencyArray) {
|
||||||
const currency: Currency = {
|
const currency: Currency = {
|
||||||
id: parseInt(String(currencyData.id)) || 0,
|
id: Number.parseInt(String(currencyData.id)) || 0,
|
||||||
code: String(currencyData.code || ""),
|
code: String(currencyData.code || ""),
|
||||||
prefix: String(currencyData.prefix || ""),
|
prefix: String(currencyData.prefix || ""),
|
||||||
suffix: String(currencyData.suffix || ""),
|
suffix: String(currencyData.suffix || ""),
|
||||||
@ -194,14 +195,15 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const currencyIndices = currencyKeys
|
const currencyIndices = currencyKeys
|
||||||
.map(key => {
|
.map(key => {
|
||||||
const match = key.match(/currencies\[currency\]\[(\d+)\]\[id\]/);
|
const match = key.match(/currencies\[currency\]\[(\d+)\]\[id\]/);
|
||||||
return match ? parseInt(match[1], 10) : null;
|
const indexStr = match?.[1];
|
||||||
|
return indexStr === undefined ? null : Number.parseInt(indexStr, 10);
|
||||||
})
|
})
|
||||||
.filter((index): index is number => index !== null);
|
.filter((index): index is number => index !== null);
|
||||||
|
|
||||||
// Build currency objects from the flat response
|
// Build currency objects from the flat response
|
||||||
for (const index of currencyIndices) {
|
for (const index of currencyIndices) {
|
||||||
const currency: Currency = {
|
const currency: Currency = {
|
||||||
id: parseInt(String(response[`currencies[currency][${index}][id]`])) || 0,
|
id: Number.parseInt(String(response[`currencies[currency][${index}][id]`])) || 0,
|
||||||
code: String(response[`currencies[currency][${index}][code]`] || ""),
|
code: String(response[`currencies[currency][${index}][code]`] || ""),
|
||||||
prefix: String(response[`currencies[currency][${index}][prefix]`] || ""),
|
prefix: String(response[`currencies[currency][${index}][prefix]`] || ""),
|
||||||
suffix: String(response[`currencies[currency][${index}][suffix]`] || ""),
|
suffix: String(response[`currencies[currency][${index}][suffix]`] || ""),
|
||||||
|
|||||||
@ -14,8 +14,6 @@ import type {
|
|||||||
WhmcsCreateInvoiceParams,
|
WhmcsCreateInvoiceParams,
|
||||||
WhmcsUpdateInvoiceParams,
|
WhmcsUpdateInvoiceParams,
|
||||||
WhmcsCapturePaymentParams,
|
WhmcsCapturePaymentParams,
|
||||||
} from "@customer-portal/domain/billing/providers";
|
|
||||||
import type {
|
|
||||||
WhmcsInvoiceListResponse,
|
WhmcsInvoiceListResponse,
|
||||||
WhmcsInvoiceResponse,
|
WhmcsInvoiceResponse,
|
||||||
WhmcsCreateInvoiceResponse,
|
WhmcsCreateInvoiceResponse,
|
||||||
@ -66,7 +64,7 @@ export class WhmcsInvoiceService {
|
|||||||
limitnum: limit,
|
limitnum: limit,
|
||||||
orderby: "date",
|
orderby: "date",
|
||||||
order: "DESC",
|
order: "DESC",
|
||||||
...(status && { status: status as WhmcsGetInvoicesParams["status"] }),
|
...(status ? { status: status as WhmcsGetInvoicesParams["status"] } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response: WhmcsInvoiceListResponse = await this.connectionService.getInvoices(params);
|
const response: WhmcsInvoiceListResponse = await this.connectionService.getInvoices(params);
|
||||||
@ -118,6 +116,7 @@ export class WhmcsInvoiceService {
|
|||||||
|
|
||||||
for (let i = 0; i < batches.length; i++) {
|
for (let i = 0; i < batches.length; i++) {
|
||||||
const batch = batches[i];
|
const batch = batches[i];
|
||||||
|
if (!batch) continue;
|
||||||
|
|
||||||
// Process batch in parallel
|
// Process batch in parallel
|
||||||
const batchResults = await Promise.all(
|
const batchResults = await Promise.all(
|
||||||
@ -309,10 +308,10 @@ export class WhmcsInvoiceService {
|
|||||||
status: "Unpaid",
|
status: "Unpaid",
|
||||||
sendnotification: false, // Don't send email notification automatically
|
sendnotification: false, // Don't send email notification automatically
|
||||||
duedate: dueDateStr,
|
duedate: dueDateStr,
|
||||||
notes: params.notes,
|
|
||||||
itemdescription1: params.description,
|
itemdescription1: params.description,
|
||||||
itemamount1: params.amount,
|
itemamount1: params.amount,
|
||||||
itemtaxed1: false, // No tax for data top-ups for now
|
itemtaxed1: false, // No tax for data top-ups for now
|
||||||
|
...(params.notes === undefined ? {} : { notes: params.notes }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response: WhmcsCreateInvoiceResponse =
|
const response: WhmcsCreateInvoiceResponse =
|
||||||
@ -372,9 +371,11 @@ export class WhmcsInvoiceService {
|
|||||||
|
|
||||||
const whmcsParams: WhmcsUpdateInvoiceParams = {
|
const whmcsParams: WhmcsUpdateInvoiceParams = {
|
||||||
invoiceid: params.invoiceId,
|
invoiceid: params.invoiceId,
|
||||||
status: statusForUpdate,
|
...(statusForUpdate === undefined ? {} : { status: statusForUpdate }),
|
||||||
duedate: params.dueDate ? params.dueDate.toISOString().split("T")[0] : undefined,
|
...(params.dueDate === undefined
|
||||||
notes: params.notes,
|
? {}
|
||||||
|
: { duedate: params.dueDate.toISOString().split("T")[0] }),
|
||||||
|
...(params.notes === undefined ? {} : { notes: params.notes }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response: WhmcsUpdateInvoiceResponse =
|
const response: WhmcsUpdateInvoiceResponse =
|
||||||
@ -394,7 +395,7 @@ export class WhmcsInvoiceService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: response.message,
|
...(response.message === undefined ? {} : { message: response.message }),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to update invoice ${params.invoiceId}`, {
|
this.logger.error(`Failed to update invoice ${params.invoiceId}`, {
|
||||||
@ -436,7 +437,9 @@ export class WhmcsInvoiceService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
transactionId: response.transactionid,
|
...(response.transactionid === undefined
|
||||||
|
? {}
|
||||||
|
: { transactionId: response.transactionid }),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`Payment capture failed for invoice ${params.invoiceId}`, {
|
this.logger.warn(`Payment capture failed for invoice ${params.invoiceId}`, {
|
||||||
|
|||||||
@ -10,8 +10,8 @@ import type {
|
|||||||
WhmcsAddOrderResponse,
|
WhmcsAddOrderResponse,
|
||||||
WhmcsOrderResult,
|
WhmcsOrderResult,
|
||||||
} from "@customer-portal/domain/orders/providers";
|
} from "@customer-portal/domain/orders/providers";
|
||||||
import { buildWhmcsAddOrderPayload } from "@customer-portal/domain/orders/providers";
|
|
||||||
import {
|
import {
|
||||||
|
buildWhmcsAddOrderPayload,
|
||||||
whmcsAddOrderResponseSchema,
|
whmcsAddOrderResponseSchema,
|
||||||
whmcsAcceptOrderResponseSchema,
|
whmcsAcceptOrderResponseSchema,
|
||||||
} from "@customer-portal/domain/orders/providers";
|
} from "@customer-portal/domain/orders/providers";
|
||||||
@ -47,14 +47,16 @@ export class WhmcsOrderService {
|
|||||||
|
|
||||||
this.logger.debug("Built WHMCS AddOrder payload", {
|
this.logger.debug("Built WHMCS AddOrder payload", {
|
||||||
clientId: params.clientId,
|
clientId: params.clientId,
|
||||||
productCount: Array.isArray(addOrderPayload.pid) ? addOrderPayload.pid.length : 0,
|
productCount: Array.isArray(addOrderPayload["pid"])
|
||||||
pids: addOrderPayload.pid,
|
? (addOrderPayload["pid"] as unknown[]).length
|
||||||
quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added
|
: 0,
|
||||||
billingCycles: addOrderPayload.billingcycle,
|
pids: addOrderPayload["pid"],
|
||||||
hasConfigOptions: Boolean(addOrderPayload.configoptions),
|
quantities: addOrderPayload["qty"], // CRITICAL: Must be included for products to be added
|
||||||
hasCustomFields: Boolean(addOrderPayload.customfields),
|
billingCycles: addOrderPayload["billingcycle"],
|
||||||
promoCode: addOrderPayload.promocode,
|
hasConfigOptions: Boolean(addOrderPayload["configoptions"]),
|
||||||
paymentMethod: addOrderPayload.paymentmethod,
|
hasCustomFields: Boolean(addOrderPayload["customfields"]),
|
||||||
|
promoCode: addOrderPayload["promocode"],
|
||||||
|
paymentMethod: addOrderPayload["paymentmethod"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Call WHMCS AddOrder API
|
// Call WHMCS AddOrder API
|
||||||
@ -104,7 +106,7 @@ export class WhmcsOrderService {
|
|||||||
sfOrderId: params.sfOrderId,
|
sfOrderId: params.sfOrderId,
|
||||||
itemCount: params.items.length,
|
itemCount: params.items.length,
|
||||||
// Include first 100 chars of error stack for debugging
|
// Include first 100 chars of error stack for debugging
|
||||||
errorStack: error instanceof Error ? error.stack?.substring(0, 100) : undefined,
|
errorStack: error instanceof Error ? error.stack?.slice(0, 100) : undefined,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -163,7 +165,7 @@ export class WhmcsOrderService {
|
|||||||
orderId,
|
orderId,
|
||||||
sfOrderId,
|
sfOrderId,
|
||||||
// Include first 100 chars of error stack for debugging
|
// Include first 100 chars of error stack for debugging
|
||||||
errorStack: error instanceof Error ? error.stack?.substring(0, 100) : undefined,
|
errorStack: error instanceof Error ? error.stack?.slice(0, 100) : undefined,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -179,7 +181,8 @@ export class WhmcsOrderService {
|
|||||||
id: orderId.toString(),
|
id: orderId.toString(),
|
||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
|
|
||||||
return (response.orders as { order?: Record<string, unknown>[] })?.order?.[0] || null;
|
const orders = response["orders"] as { order?: Record<string, unknown>[] } | undefined;
|
||||||
|
return orders?.order?.[0] ?? null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to get WHMCS order details", {
|
this.logger.error("Failed to get WHMCS order details", {
|
||||||
error: extractErrorMessage(error),
|
error: extractErrorMessage(error),
|
||||||
@ -231,17 +234,17 @@ export class WhmcsOrderService {
|
|||||||
this.logger.debug("Built WHMCS AddOrder payload", {
|
this.logger.debug("Built WHMCS AddOrder payload", {
|
||||||
clientId: params.clientId,
|
clientId: params.clientId,
|
||||||
productCount: params.items.length,
|
productCount: params.items.length,
|
||||||
pids: payload.pid,
|
pids: payload["pid"],
|
||||||
billingCycles: payload.billingcycle,
|
billingCycles: payload["billingcycle"],
|
||||||
hasConfigOptions: !!payload.configoptions,
|
hasConfigOptions: !!payload["configoptions"],
|
||||||
hasCustomFields: !!payload.customfields,
|
hasCustomFields: !!payload["customfields"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return payload as Record<string, unknown>;
|
return payload as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toWhmcsOrderResult(response: WhmcsAddOrderResponse): WhmcsOrderResult {
|
private toWhmcsOrderResult(response: WhmcsAddOrderResponse): WhmcsOrderResult {
|
||||||
const orderId = parseInt(String(response.orderid), 10);
|
const orderId = Number.parseInt(String(response.orderid), 10);
|
||||||
if (!orderId || Number.isNaN(orderId)) {
|
if (!orderId || Number.isNaN(orderId)) {
|
||||||
throw new WhmcsOperationException("WHMCS AddOrder did not return valid order ID", {
|
throw new WhmcsOperationException("WHMCS AddOrder did not return valid order ID", {
|
||||||
response,
|
response,
|
||||||
@ -250,7 +253,7 @@ export class WhmcsOrderService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
orderId,
|
orderId,
|
||||||
invoiceId: response.invoiceid ? parseInt(String(response.invoiceid), 10) : undefined,
|
invoiceId: response.invoiceid ? Number.parseInt(String(response.invoiceid), 10) : undefined,
|
||||||
serviceIds: this.parseDelimitedIds(response.serviceids),
|
serviceIds: this.parseDelimitedIds(response.serviceids),
|
||||||
addonIds: this.parseDelimitedIds(response.addonids),
|
addonIds: this.parseDelimitedIds(response.addonids),
|
||||||
domainIds: this.parseDelimitedIds(response.domainids),
|
domainIds: this.parseDelimitedIds(response.domainids),
|
||||||
@ -264,7 +267,7 @@ export class WhmcsOrderService {
|
|||||||
return value
|
return value
|
||||||
.toString()
|
.toString()
|
||||||
.split(",")
|
.split(",")
|
||||||
.map(entry => parseInt(entry.trim(), 10))
|
.map(entry => Number.parseInt(entry.trim(), 10))
|
||||||
.filter(id => !Number.isNaN(id));
|
.filter(id => !Number.isNaN(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,8 +16,8 @@ import { transformWhmcsCatalogProductsResponse } from "@customer-portal/domain/s
|
|||||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
|
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
|
||||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
|
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
|
||||||
import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer/providers";
|
import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer/providers";
|
||||||
import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments/providers";
|
|
||||||
import type {
|
import type {
|
||||||
|
WhmcsGetPayMethodsParams,
|
||||||
WhmcsPaymentMethod,
|
WhmcsPaymentMethod,
|
||||||
WhmcsPaymentMethodListResponse,
|
WhmcsPaymentMethodListResponse,
|
||||||
WhmcsPaymentGateway,
|
WhmcsPaymentGateway,
|
||||||
@ -270,7 +270,7 @@ export class WhmcsPaymentService {
|
|||||||
* Debug helper: log only the host of the SSO URL (never the token) in non-production.
|
* Debug helper: log only the host of the SSO URL (never the token) in non-production.
|
||||||
*/
|
*/
|
||||||
private debugLogRedirectHost(url: string): void {
|
private debugLogRedirectHost(url: string): void {
|
||||||
if (process.env.NODE_ENV === "production") return;
|
if (process.env["NODE_ENV"] === "production") return;
|
||||||
try {
|
try {
|
||||||
const target = new URL(url);
|
const target = new URL(url);
|
||||||
const base = new URL(this.connectionService.getBaseUrl());
|
const base = new URL(this.connectionService.getBaseUrl());
|
||||||
|
|||||||
@ -207,7 +207,7 @@ export class WhmcsSsoService {
|
|||||||
* Debug helper: log only the host of the SSO URL (never the token) in non-production.
|
* Debug helper: log only the host of the SSO URL (never the token) in non-production.
|
||||||
*/
|
*/
|
||||||
private debugLogRedirectHost(url: string): void {
|
private debugLogRedirectHost(url: string): void {
|
||||||
if (process.env.NODE_ENV === "production") return;
|
if (process.env["NODE_ENV"] === "production") return;
|
||||||
try {
|
try {
|
||||||
const target = new URL(url);
|
const target = new URL(url);
|
||||||
const base = new URL(this.connectionService.getBaseUrl());
|
const base = new URL(this.connectionService.getBaseUrl());
|
||||||
|
|||||||
@ -15,7 +15,6 @@ for (const signal of signals) {
|
|||||||
if (!app) {
|
if (!app) {
|
||||||
logger.warn("Nest application not initialized. Exiting immediately.");
|
logger.warn("Nest application not initialized. Exiting immediately.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -109,15 +109,14 @@ export class AuthFacade {
|
|||||||
|
|
||||||
const profile = mapPrismaUserToDomain(prismaUser);
|
const profile = mapPrismaUserToDomain(prismaUser);
|
||||||
|
|
||||||
|
const userAgent = request?.headers?.["user-agent"];
|
||||||
const tokens = await this.tokenService.generateTokenPair(
|
const tokens = await this.tokenService.generateTokenPair(
|
||||||
{
|
{
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
role: prismaUser.role || "USER",
|
role: prismaUser.role || "USER",
|
||||||
},
|
},
|
||||||
{
|
userAgent ? { userAgent } : {}
|
||||||
userAgent: request?.headers["user-agent"],
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.updateAccountLastSignIn(user.id);
|
await this.updateAccountLastSignIn(user.id);
|
||||||
@ -290,7 +289,7 @@ export class AuthFacade {
|
|||||||
|
|
||||||
async refreshTokens(
|
async refreshTokens(
|
||||||
refreshToken: string | undefined,
|
refreshToken: string | undefined,
|
||||||
deviceInfo?: { deviceId?: string; userAgent?: string }
|
deviceInfo?: { deviceId?: string | undefined; userAgent?: string | undefined }
|
||||||
) {
|
) {
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new UnauthorizedException("Invalid refresh token");
|
throw new UnauthorizedException("Invalid refresh token");
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import type { User } from "@customer-portal/domain/customer";
|
import type { User, UserAuth } from "@customer-portal/domain/customer";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import type { AuthTokens } from "@customer-portal/domain/auth";
|
import type { AuthTokens } from "@customer-portal/domain/auth";
|
||||||
import type { UserAuth } from "@customer-portal/domain/customer";
|
|
||||||
|
|
||||||
export type RequestWithUser = Request & { user: User };
|
export type RequestWithUser = Request & { user: User };
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type {
|
|||||||
} from "@customer-portal/domain/get-started";
|
} from "@customer-portal/domain/get-started";
|
||||||
|
|
||||||
import { CacheService } from "@/infra/cache/cache.service.js";
|
import { CacheService } from "@/infra/cache/cache.service.js";
|
||||||
|
import { DistributedLockService } from "@/infra/cache/distributed-lock.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Session data stored in Redis (internal representation)
|
* Session data stored in Redis (internal representation)
|
||||||
@ -18,6 +19,10 @@ import { CacheService } from "@/infra/cache/cache.service.js";
|
|||||||
interface SessionData extends Omit<GetStartedSession, "expiresAt"> {
|
interface SessionData extends Omit<GetStartedSession, "expiresAt"> {
|
||||||
/** Session ID (for lookup) */
|
/** Session ID (for lookup) */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Timestamp when session was marked as used (for one-time operations) */
|
||||||
|
usedAt?: string;
|
||||||
|
/** The operation that used this session */
|
||||||
|
usedFor?: "guest_eligibility" | "signup_with_eligibility" | "complete_account";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -36,11 +41,13 @@ interface SessionData extends Omit<GetStartedSession, "expiresAt"> {
|
|||||||
export class GetStartedSessionService {
|
export class GetStartedSessionService {
|
||||||
private readonly SESSION_PREFIX = "get-started-session:";
|
private readonly SESSION_PREFIX = "get-started-session:";
|
||||||
private readonly HANDOFF_PREFIX = "guest-handoff:";
|
private readonly HANDOFF_PREFIX = "guest-handoff:";
|
||||||
|
private readonly SESSION_LOCK_PREFIX = "session-lock:";
|
||||||
private readonly ttlSeconds: number;
|
private readonly ttlSeconds: number;
|
||||||
private readonly handoffTtlSeconds = 1800; // 30 minutes for handoff tokens
|
private readonly handoffTtlSeconds = 1800; // 30 minutes for handoff tokens
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
|
private readonly lockService: DistributedLockService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
@ -277,6 +284,148 @@ export class GetStartedSessionService {
|
|||||||
this.logger.debug({ tokenId: token }, "Guest handoff token invalidated");
|
this.logger.debug({ tokenId: token }, "Guest handoff token invalidated");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session Locking (Idempotency Protection)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically acquire a lock and mark the session as used for a specific operation.
|
||||||
|
*
|
||||||
|
* This prevents race conditions where the same session could be used
|
||||||
|
* multiple times (e.g., double-clicking "Create Account").
|
||||||
|
*
|
||||||
|
* @param sessionToken - Session token
|
||||||
|
* @param operation - The operation being performed
|
||||||
|
* @returns Object with success flag and session data if acquired
|
||||||
|
*/
|
||||||
|
async acquireAndMarkAsUsed(
|
||||||
|
sessionToken: string,
|
||||||
|
operation: SessionData["usedFor"]
|
||||||
|
): Promise<{ success: true; session: GetStartedSession } | { success: false; reason: string }> {
|
||||||
|
const lockKey = `${this.SESSION_LOCK_PREFIX}${sessionToken}`;
|
||||||
|
|
||||||
|
// Try to acquire lock with no retries (immediate fail if already locked)
|
||||||
|
const lockResult = await this.lockService.tryWithLock(
|
||||||
|
lockKey,
|
||||||
|
async () => {
|
||||||
|
// Check session state within lock
|
||||||
|
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
|
||||||
|
|
||||||
|
if (!sessionData) {
|
||||||
|
return { success: false as const, reason: "Session not found or expired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionData.emailVerified) {
|
||||||
|
return { success: false as const, reason: "Session email not verified" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionData.usedAt) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ sessionId: sessionToken, usedFor: sessionData.usedFor, usedAt: sessionData.usedAt },
|
||||||
|
"Session already used"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: false as const,
|
||||||
|
reason: `Session already used for ${sessionData.usedFor}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as used - build object with required fields, then add optional fields
|
||||||
|
const updatedData = Object.assign(
|
||||||
|
{
|
||||||
|
id: sessionData.id,
|
||||||
|
email: sessionData.email,
|
||||||
|
emailVerified: sessionData.emailVerified,
|
||||||
|
createdAt: sessionData.createdAt,
|
||||||
|
usedAt: new Date().toISOString(),
|
||||||
|
usedFor: operation,
|
||||||
|
},
|
||||||
|
sessionData.accountStatus ? { accountStatus: sessionData.accountStatus } : {},
|
||||||
|
sessionData.firstName ? { firstName: sessionData.firstName } : {},
|
||||||
|
sessionData.lastName ? { lastName: sessionData.lastName } : {},
|
||||||
|
sessionData.phone ? { phone: sessionData.phone } : {},
|
||||||
|
sessionData.address ? { address: sessionData.address } : {},
|
||||||
|
sessionData.sfAccountId ? { sfAccountId: sessionData.sfAccountId } : {},
|
||||||
|
sessionData.whmcsClientId === undefined
|
||||||
|
? {}
|
||||||
|
: { whmcsClientId: sessionData.whmcsClientId },
|
||||||
|
sessionData.eligibilityStatus ? { eligibilityStatus: sessionData.eligibilityStatus } : {}
|
||||||
|
) as SessionData;
|
||||||
|
|
||||||
|
const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt);
|
||||||
|
await this.cache.set(this.buildKey(sessionToken), updatedData, remainingTtl);
|
||||||
|
|
||||||
|
this.logger.debug({ sessionId: sessionToken, operation }, "Session marked as used");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
session: {
|
||||||
|
...updatedData,
|
||||||
|
expiresAt: this.calculateExpiresAt(sessionData.createdAt),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ ttlMs: 65_000, maxRetries: 0 } // TTL must exceed workflow lock (60s) - fail fast
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lockResult.success) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ sessionId: sessionToken },
|
||||||
|
"Failed to acquire session lock - operation in progress"
|
||||||
|
);
|
||||||
|
return { success: false, reason: "Operation already in progress" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return lockResult.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a session has already been used for an operation
|
||||||
|
*/
|
||||||
|
async isSessionUsed(sessionToken: string): Promise<boolean> {
|
||||||
|
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
|
||||||
|
return sessionData?.usedAt != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the "used" status from a session (for recovery after partial failure)
|
||||||
|
*
|
||||||
|
* This should only be called when rolling back a failed operation
|
||||||
|
* to allow the user to retry.
|
||||||
|
*/
|
||||||
|
async clearUsedStatus(sessionToken: string): Promise<boolean> {
|
||||||
|
const sessionData = await this.cache.get<SessionData>(this.buildKey(sessionToken));
|
||||||
|
|
||||||
|
if (!sessionData) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build clean session data without usedAt and usedFor
|
||||||
|
const cleanSessionData = Object.assign(
|
||||||
|
{
|
||||||
|
id: sessionData.id,
|
||||||
|
email: sessionData.email,
|
||||||
|
emailVerified: sessionData.emailVerified,
|
||||||
|
createdAt: sessionData.createdAt,
|
||||||
|
},
|
||||||
|
sessionData.accountStatus ? { accountStatus: sessionData.accountStatus } : {},
|
||||||
|
sessionData.firstName ? { firstName: sessionData.firstName } : {},
|
||||||
|
sessionData.lastName ? { lastName: sessionData.lastName } : {},
|
||||||
|
sessionData.phone ? { phone: sessionData.phone } : {},
|
||||||
|
sessionData.address ? { address: sessionData.address } : {},
|
||||||
|
sessionData.sfAccountId ? { sfAccountId: sessionData.sfAccountId } : {},
|
||||||
|
sessionData.whmcsClientId === undefined ? {} : { whmcsClientId: sessionData.whmcsClientId },
|
||||||
|
sessionData.eligibilityStatus ? { eligibilityStatus: sessionData.eligibilityStatus } : {}
|
||||||
|
) as SessionData;
|
||||||
|
|
||||||
|
const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt);
|
||||||
|
await this.cache.set(this.buildKey(sessionToken), cleanSessionData, remainingTtl);
|
||||||
|
|
||||||
|
this.logger.debug({ sessionId: sessionToken }, "Session used status cleared for retry");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private buildKey(sessionId: string): string {
|
private buildKey(sessionId: string): string {
|
||||||
return `${this.SESSION_PREFIX}${sessionId}`;
|
return `${this.SESSION_PREFIX}${sessionId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ interface OtpData {
|
|||||||
/** When the code was created */
|
/** When the code was created */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Optional fingerprint binding (SHA256 hash of IP + User-Agent) */
|
/** Optional fingerprint binding (SHA256 hash of IP + User-Agent) */
|
||||||
fingerprint?: string;
|
fingerprint?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -133,7 +133,6 @@ export class AuthRateLimitService {
|
|||||||
points: limit,
|
points: limit,
|
||||||
duration,
|
duration,
|
||||||
inMemoryBlockOnConsumed: limit + 1,
|
inMemoryBlockOnConsumed: limit + 1,
|
||||||
insuranceLimiter: undefined,
|
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
|
|||||||
@ -7,10 +7,10 @@ import { parseJwtExpiry } from "../../utils/jwt-expiry.util.js";
|
|||||||
export class JoseJwtService {
|
export class JoseJwtService {
|
||||||
private readonly signingKey: Uint8Array;
|
private readonly signingKey: Uint8Array;
|
||||||
private readonly verificationKeys: Uint8Array[];
|
private readonly verificationKeys: Uint8Array[];
|
||||||
private readonly issuer?: string;
|
private readonly issuer: string | undefined;
|
||||||
private readonly audience?: string | string[];
|
private readonly audience: string | string[] | undefined;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(configService: ConfigService) {
|
||||||
const secret = configService.get<string>("JWT_SECRET");
|
const secret = configService.get<string>("JWT_SECRET");
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error("JWT_SECRET is required in environment variables");
|
throw new Error("JWT_SECRET is required in environment variables");
|
||||||
@ -83,15 +83,16 @@ export class JoseJwtService {
|
|||||||
|
|
||||||
async verify<T extends JWTPayload>(token: string): Promise<T> {
|
async verify<T extends JWTPayload>(token: string): Promise<T> {
|
||||||
const options = {
|
const options = {
|
||||||
algorithms: ["HS256"],
|
algorithms: ["HS256"] as string[],
|
||||||
issuer: this.issuer,
|
...(this.issuer === undefined ? {} : { issuer: this.issuer }),
|
||||||
audience: this.audience,
|
...(this.audience === undefined ? {} : { audience: this.audience }),
|
||||||
};
|
};
|
||||||
|
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
for (let i = 0; i < this.verificationKeys.length; i++) {
|
for (let i = 0; i < this.verificationKeys.length; i++) {
|
||||||
const key = this.verificationKeys[i];
|
const key = this.verificationKeys[i];
|
||||||
|
if (!key) continue;
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, key, options);
|
const { payload } = await jwtVerify(token, key, options);
|
||||||
return payload as T;
|
return payload as T;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { Redis } from "ioredis";
|
import { Redis } from "ioredis";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
|
||||||
@ -32,8 +31,7 @@ export class TokenMigrationService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger
|
||||||
private readonly configService: ConfigService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -415,7 +413,7 @@ export class TokenMigrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const record = parsed as Record<string, unknown>;
|
const record = parsed as Record<string, unknown>;
|
||||||
const userId = record.userId;
|
const userId = record["userId"];
|
||||||
|
|
||||||
if (typeof userId !== "string" || userId.length === 0) {
|
if (typeof userId !== "string" || userId.length === 0) {
|
||||||
this.logger.warn("Invalid family structure, skipping", { familyKey });
|
this.logger.warn("Invalid family structure, skipping", { familyKey });
|
||||||
@ -448,8 +446,8 @@ export class TokenMigrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const record = parsed as Record<string, unknown>;
|
const record = parsed as Record<string, unknown>;
|
||||||
const userId = record.userId;
|
const userId = record["userId"];
|
||||||
const familyId = record.familyId;
|
const familyId = record["familyId"];
|
||||||
|
|
||||||
if (typeof userId !== "string" || typeof familyId !== "string") {
|
if (typeof userId !== "string" || typeof familyId !== "string") {
|
||||||
this.logger.warn("Invalid token structure, skipping", { tokenKey });
|
this.logger.warn("Invalid token structure, skipping", { tokenKey });
|
||||||
|
|||||||
@ -145,19 +145,22 @@ export class TokenRevocationService {
|
|||||||
/**
|
/**
|
||||||
* Get all active refresh token families for a user
|
* Get all active refresh token families for a user
|
||||||
*/
|
*/
|
||||||
async getUserRefreshTokenFamilies(
|
async getUserRefreshTokenFamilies(userId: string): Promise<
|
||||||
userId: string
|
Array<{
|
||||||
): Promise<
|
familyId: string;
|
||||||
Array<{ familyId: string; deviceId?: string; userAgent?: string; createdAt?: string }>
|
deviceId?: string | undefined;
|
||||||
|
userAgent?: string | undefined;
|
||||||
|
createdAt?: string | undefined;
|
||||||
|
}>
|
||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
const familyIds = await this.storage.getUserFamilyIds(userId);
|
const familyIds = await this.storage.getUserFamilyIds(userId);
|
||||||
|
|
||||||
const families: Array<{
|
const families: Array<{
|
||||||
familyId: string;
|
familyId: string;
|
||||||
deviceId?: string;
|
deviceId?: string | undefined;
|
||||||
userAgent?: string;
|
userAgent?: string | undefined;
|
||||||
createdAt?: string;
|
createdAt?: string | undefined;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const familyId of familyIds) {
|
for (const familyId of familyIds) {
|
||||||
|
|||||||
@ -11,10 +11,10 @@ export interface StoredRefreshToken {
|
|||||||
export interface StoredRefreshTokenFamily {
|
export interface StoredRefreshTokenFamily {
|
||||||
userId: string;
|
userId: string;
|
||||||
tokenHash: string;
|
tokenHash: string;
|
||||||
deviceId?: string;
|
deviceId?: string | undefined;
|
||||||
userAgent?: string;
|
userAgent?: string | undefined;
|
||||||
createdAt?: string;
|
createdAt?: string | undefined;
|
||||||
absoluteExpiresAt?: string;
|
absoluteExpiresAt?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -41,7 +41,7 @@ export class TokenStorageService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
familyId: string,
|
familyId: string,
|
||||||
refreshTokenHash: string,
|
refreshTokenHash: string,
|
||||||
deviceInfo?: { deviceId?: string; userAgent?: string },
|
deviceInfo?: { deviceId?: string | undefined; userAgent?: string | undefined },
|
||||||
refreshExpirySeconds?: number,
|
refreshExpirySeconds?: number,
|
||||||
absoluteExpiresAt?: string
|
absoluteExpiresAt?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@ -86,7 +86,7 @@ export class TokenStorageService {
|
|||||||
const results = await pipeline.exec();
|
const results = await pipeline.exec();
|
||||||
|
|
||||||
// Check if user has too many tokens
|
// Check if user has too many tokens
|
||||||
const cardResult = results?.[results.length - 1];
|
const cardResult = results?.at(-1);
|
||||||
if (
|
if (
|
||||||
cardResult &&
|
cardResult &&
|
||||||
Array.isArray(cardResult) &&
|
Array.isArray(cardResult) &&
|
||||||
@ -155,7 +155,7 @@ export class TokenStorageService {
|
|||||||
familyId: string,
|
familyId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
newTokenHash: string,
|
newTokenHash: string,
|
||||||
deviceInfo: { deviceId?: string; userAgent?: string } | undefined,
|
deviceInfo: { deviceId?: string | undefined; userAgent?: string | undefined } | undefined,
|
||||||
createdAt: string,
|
createdAt: string,
|
||||||
absoluteExpiresAt: string,
|
absoluteExpiresAt: string,
|
||||||
ttlSeconds: number
|
ttlSeconds: number
|
||||||
|
|||||||
@ -23,14 +23,14 @@ export interface RefreshTokenPayload extends JWTPayload {
|
|||||||
* Refresh token family identifier (stable across rotations).
|
* Refresh token family identifier (stable across rotations).
|
||||||
* Present on newly issued tokens; legacy tokens used `tokenId` for this value.
|
* Present on newly issued tokens; legacy tokens used `tokenId` for this value.
|
||||||
*/
|
*/
|
||||||
familyId?: string;
|
familyId?: string | undefined;
|
||||||
/**
|
/**
|
||||||
* Refresh token identifier (unique per token). Used for replay/reuse detection.
|
* Refresh token identifier (unique per token). Used for replay/reuse detection.
|
||||||
* For legacy tokens, this was equal to the family id.
|
* For legacy tokens, this was equal to the family id.
|
||||||
*/
|
*/
|
||||||
tokenId: string;
|
tokenId: string;
|
||||||
deviceId?: string;
|
deviceId?: string | undefined;
|
||||||
userAgent?: string;
|
userAgent?: string | undefined;
|
||||||
type: "refresh";
|
type: "refresh";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,8 +201,8 @@ export class AuthTokenService {
|
|||||||
async refreshTokens(
|
async refreshTokens(
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
deviceInfo?: {
|
deviceInfo?: {
|
||||||
deviceId?: string;
|
deviceId?: string | undefined;
|
||||||
userAgent?: string;
|
userAgent?: string | undefined;
|
||||||
}
|
}
|
||||||
): Promise<{ tokens: AuthTokens; user: User }> {
|
): Promise<{ tokens: AuthTokens; user: User }> {
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
@ -297,10 +297,10 @@ export class AuthTokenService {
|
|||||||
|
|
||||||
if (absoluteExpiresAt) {
|
if (absoluteExpiresAt) {
|
||||||
const absMs = Date.parse(absoluteExpiresAt);
|
const absMs = Date.parse(absoluteExpiresAt);
|
||||||
if (!Number.isNaN(absMs)) {
|
if (Number.isNaN(absMs)) {
|
||||||
remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000));
|
|
||||||
} else {
|
|
||||||
absoluteExpiresAt = undefined;
|
absoluteExpiresAt = undefined;
|
||||||
|
} else {
|
||||||
|
remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -426,10 +426,13 @@ export class AuthTokenService {
|
|||||||
/**
|
/**
|
||||||
* Get all active refresh token families for a user
|
* Get all active refresh token families for a user
|
||||||
*/
|
*/
|
||||||
async getUserRefreshTokenFamilies(
|
async getUserRefreshTokenFamilies(userId: string): Promise<
|
||||||
userId: string
|
Array<{
|
||||||
): Promise<
|
familyId: string;
|
||||||
Array<{ familyId: string; deviceId?: string; userAgent?: string; createdAt?: string }>
|
deviceId?: string | undefined;
|
||||||
|
userAgent?: string | undefined;
|
||||||
|
createdAt?: string | undefined;
|
||||||
|
}>
|
||||||
> {
|
> {
|
||||||
return this.revocation.getUserRefreshTokenFamilies(userId);
|
return this.revocation.getUserRefreshTokenFamilies(userId);
|
||||||
}
|
}
|
||||||
@ -451,7 +454,7 @@ export class AuthTokenService {
|
|||||||
|
|
||||||
private parseExpiryToMs(expiry: string): number {
|
private parseExpiryToMs(expiry: string): number {
|
||||||
const unit = expiry.slice(-1);
|
const unit = expiry.slice(-1);
|
||||||
const value = parseInt(expiry.slice(0, -1));
|
const value = Number.parseInt(expiry.slice(0, -1));
|
||||||
|
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case "s":
|
case "s":
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import * as argon2 from "argon2";
|
import * as argon2 from "argon2";
|
||||||
|
|
||||||
|
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ACCOUNT_STATUS,
|
ACCOUNT_STATUS,
|
||||||
type AccountStatus,
|
type AccountStatus,
|
||||||
@ -17,6 +19,7 @@ import {
|
|||||||
type CompleteAccountRequest,
|
type CompleteAccountRequest,
|
||||||
type MaybeLaterRequest,
|
type MaybeLaterRequest,
|
||||||
type MaybeLaterResponse,
|
type MaybeLaterResponse,
|
||||||
|
type SignupWithEligibilityRequest,
|
||||||
} from "@customer-portal/domain/get-started";
|
} from "@customer-portal/domain/get-started";
|
||||||
|
|
||||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||||
@ -43,6 +46,15 @@ import {
|
|||||||
} from "@bff/modules/auth/constants/portal.constants.js";
|
} from "@bff/modules/auth/constants/portal.constants.js";
|
||||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove undefined properties from an object (for exactOptionalPropertyTypes compatibility)
|
||||||
|
*/
|
||||||
|
function removeUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj).filter(([, value]) => value !== undefined)
|
||||||
|
) as Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Started Workflow Service
|
* Get Started Workflow Service
|
||||||
*
|
*
|
||||||
@ -70,6 +82,7 @@ export class GetStartedWorkflowService {
|
|||||||
private readonly whmcsSignup: SignupWhmcsService,
|
private readonly whmcsSignup: SignupWhmcsService,
|
||||||
private readonly userCreation: SignupUserCreationService,
|
private readonly userCreation: SignupUserCreationService,
|
||||||
private readonly tokenService: AuthTokenService,
|
private readonly tokenService: AuthTokenService,
|
||||||
|
private readonly lockService: DistributedLockService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -155,15 +168,23 @@ export class GetStartedWorkflowService {
|
|||||||
const prefill = this.getPrefillData(normalizedEmail, accountStatus);
|
const prefill = this.getPrefillData(normalizedEmail, accountStatus);
|
||||||
|
|
||||||
// Update session with verified status and account info
|
// Update session with verified status and account info
|
||||||
await this.sessionService.markEmailVerified(sessionToken, accountStatus.status, {
|
// Build prefill data object without undefined values (exactOptionalPropertyTypes)
|
||||||
firstName: prefill?.firstName,
|
const prefillData = {
|
||||||
lastName: prefill?.lastName,
|
...(prefill?.firstName && { firstName: prefill.firstName }),
|
||||||
phone: prefill?.phone,
|
...(prefill?.lastName && { lastName: prefill.lastName }),
|
||||||
address: prefill?.address,
|
...(prefill?.phone && { phone: prefill.phone }),
|
||||||
sfAccountId: accountStatus.sfAccountId,
|
...(prefill?.address && { address: prefill.address }),
|
||||||
|
...(accountStatus.sfAccountId && { sfAccountId: accountStatus.sfAccountId }),
|
||||||
|
...(accountStatus.whmcsClientId !== undefined && {
|
||||||
whmcsClientId: accountStatus.whmcsClientId,
|
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(
|
this.logger.log(
|
||||||
{ email: normalizedEmail, accountStatus: accountStatus.status },
|
{ email: normalizedEmail, accountStatus: accountStatus.status },
|
||||||
@ -221,13 +242,13 @@ export class GetStartedWorkflowService {
|
|||||||
// Create eligibility case
|
// Create eligibility case
|
||||||
const requestId = await this.createEligibilityCase(sfAccountId, address);
|
const requestId = await this.createEligibilityCase(sfAccountId, address);
|
||||||
|
|
||||||
// Update session with SF account info
|
// Update session with SF account info (clean address to remove undefined values)
|
||||||
await this.sessionService.updateWithQuickCheckData(request.sessionToken, {
|
await this.sessionService.updateWithQuickCheckData(request.sessionToken, {
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
address,
|
address: removeUndefined(address),
|
||||||
phone,
|
...(phone && { phone }),
|
||||||
sfAccountId,
|
...(sfAccountId && { sfAccountId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -286,6 +307,9 @@ export class GetStartedWorkflowService {
|
|||||||
* Creates SF Account + eligibility case immediately.
|
* Creates SF Account + eligibility case immediately.
|
||||||
* Email verification happens later when user creates an account.
|
* Email verification happens later when user creates an account.
|
||||||
*
|
*
|
||||||
|
* Security:
|
||||||
|
* - Email-level lock prevents concurrent requests creating duplicate SF accounts
|
||||||
|
*
|
||||||
* @param request - Guest eligibility request with name, email, address
|
* @param request - Guest eligibility request with name, email, address
|
||||||
* @param fingerprint - Request fingerprint for logging/abuse detection
|
* @param fingerprint - Request fingerprint for logging/abuse detection
|
||||||
*/
|
*/
|
||||||
@ -301,7 +325,13 @@ export class GetStartedWorkflowService {
|
|||||||
"Guest eligibility check initiated"
|
"Guest eligibility check initiated"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Email-level lock to prevent concurrent requests for the same email
|
||||||
|
const lockKey = `guest-eligibility:${normalizedEmail}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
return await this.lockService.withLock(
|
||||||
|
lockKey,
|
||||||
|
async () => {
|
||||||
// Check if SF account already exists for this email
|
// Check if SF account already exists for this email
|
||||||
let sfAccountId: string;
|
let sfAccountId: string;
|
||||||
|
|
||||||
@ -340,8 +370,8 @@ export class GetStartedWorkflowService {
|
|||||||
handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, {
|
handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, {
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
address,
|
address: removeUndefined(address),
|
||||||
phone,
|
...(phone && { phone }),
|
||||||
sfAccountId,
|
sfAccountId,
|
||||||
});
|
});
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
@ -360,6 +390,9 @@ export class GetStartedWorkflowService {
|
|||||||
handoffToken,
|
handoffToken,
|
||||||
message: "Eligibility check submitted. We'll notify you of the results.",
|
message: "Eligibility check submitted. We'll notify you of the results.",
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
{ ttlMs: 30_000 } // 30 second lock timeout
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
{ error: extractErrorMessage(error), email: normalizedEmail },
|
{ error: extractErrorMessage(error), email: normalizedEmail },
|
||||||
@ -380,19 +413,35 @@ export class GetStartedWorkflowService {
|
|||||||
/**
|
/**
|
||||||
* Complete account for users with SF account but no WHMCS/Portal
|
* Complete account for users with SF account but no WHMCS/Portal
|
||||||
* Creates WHMCS client and Portal user, links to existing SF account
|
* Creates WHMCS client and Portal user, links to existing SF account
|
||||||
|
*
|
||||||
|
* Security:
|
||||||
|
* - Session is locked to prevent double submissions
|
||||||
|
* - Email-level lock prevents concurrent account creation
|
||||||
*/
|
*/
|
||||||
async completeAccount(request: CompleteAccountRequest): Promise<AuthResultInternal> {
|
async completeAccount(request: CompleteAccountRequest): Promise<AuthResultInternal> {
|
||||||
const session = await this.sessionService.validateVerifiedSession(request.sessionToken);
|
// Atomically acquire session lock and mark as used
|
||||||
if (!session) {
|
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
|
||||||
throw new BadRequestException("Invalid or expired session. Please verify your email again.");
|
request.sessionToken,
|
||||||
|
"complete_account"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sessionResult.success) {
|
||||||
|
throw new BadRequestException(sessionResult.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = sessionResult.session;
|
||||||
|
|
||||||
if (!session.sfAccountId) {
|
if (!session.sfAccountId) {
|
||||||
throw new BadRequestException("No Salesforce account found. Please check eligibility first.");
|
throw new BadRequestException("No Salesforce account found. Please check eligibility first.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { password, phone, dateOfBirth, gender } = request;
|
const { password, phone, dateOfBirth, gender } = request;
|
||||||
|
const lockKey = `complete-account:${session.email}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.lockService.withLock(
|
||||||
|
lockKey,
|
||||||
|
async () => {
|
||||||
// Verify SF account still exists
|
// Verify SF account still exists
|
||||||
const existingSf = await this.salesforceAccountService.findByEmail(session.email);
|
const existingSf = await this.salesforceAccountService.findByEmail(session.email);
|
||||||
if (!existingSf || existingSf.id !== session.sfAccountId) {
|
if (!existingSf || existingSf.id !== session.sfAccountId) {
|
||||||
@ -415,7 +464,6 @@ export class GetStartedWorkflowService {
|
|||||||
|
|
||||||
const passwordHash = await argon2.hash(password);
|
const passwordHash = await argon2.hash(password);
|
||||||
|
|
||||||
try {
|
|
||||||
// Get address from session or SF
|
// Get address from session or SF
|
||||||
const address = session.address;
|
const address = session.address;
|
||||||
if (!address || !address.address1 || !address.city || !address.postcode) {
|
if (!address || !address.address1 || !address.city || !address.postcode) {
|
||||||
@ -431,7 +479,7 @@ export class GetStartedWorkflowService {
|
|||||||
phone,
|
phone,
|
||||||
address: {
|
address: {
|
||||||
address1: address.address1,
|
address1: address.address1,
|
||||||
address2: address.address2 ?? undefined,
|
...(address.address2 && { address2: address.address2 }),
|
||||||
city: address.city,
|
city: address.city,
|
||||||
state: address.state ?? "",
|
state: address.state ?? "",
|
||||||
postcode: address.postcode,
|
postcode: address.postcode,
|
||||||
@ -471,7 +519,7 @@ export class GetStartedWorkflowService {
|
|||||||
// Update Salesforce portal flags
|
// Update Salesforce portal flags
|
||||||
await this.updateSalesforcePortalFlags(session.sfAccountId, whmcsClient.clientId);
|
await this.updateSalesforcePortalFlags(session.sfAccountId, whmcsClient.clientId);
|
||||||
|
|
||||||
// Invalidate session
|
// Invalidate session (fully done)
|
||||||
await this.sessionService.invalidate(request.sessionToken);
|
await this.sessionService.invalidate(request.sessionToken);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@ -483,15 +531,216 @@ export class GetStartedWorkflowService {
|
|||||||
user: profile,
|
user: profile,
|
||||||
tokens,
|
tokens,
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
{ ttlMs: 60_000 }
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
{ error: extractErrorMessage(error), email: session.email },
|
{ error: extractErrorMessage(error), email: session.email },
|
||||||
"Account completion failed"
|
"Account completion failed"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Don't clear usedStatus on error - partial resources may have been created.
|
||||||
|
// The user must verify their email again to start fresh.
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Full Signup with Eligibility (Inline Flow)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full signup with eligibility check - creates everything in one operation
|
||||||
|
*
|
||||||
|
* This is the primary signup path from the eligibility check page.
|
||||||
|
* Creates SF Account + Case + WHMCS + Portal after email verification.
|
||||||
|
*
|
||||||
|
* Security:
|
||||||
|
* - Session is locked to prevent double submissions (race condition protection)
|
||||||
|
* - Email-level lock prevents concurrent signups for the same email
|
||||||
|
* - Session is invalidated on success, cleared on partial failure for retry
|
||||||
|
*
|
||||||
|
* @param request - Signup request with all required data
|
||||||
|
*/
|
||||||
|
async signupWithEligibility(request: SignupWithEligibilityRequest): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
eligibilityRequestId?: string;
|
||||||
|
authResult?: AuthResultInternal;
|
||||||
|
}> {
|
||||||
|
// Atomically acquire session lock and mark as used
|
||||||
|
const sessionResult = await this.sessionService.acquireAndMarkAsUsed(
|
||||||
|
request.sessionToken,
|
||||||
|
"signup_with_eligibility"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sessionResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: sessionResult.reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessionResult.session;
|
||||||
|
const { firstName, lastName, address, phone, password, dateOfBirth, gender } = request;
|
||||||
|
const normalizedEmail = session.email;
|
||||||
|
|
||||||
|
this.logger.log({ email: normalizedEmail }, "Starting signup with eligibility");
|
||||||
|
|
||||||
|
// Email-level lock to prevent concurrent signups for the same email
|
||||||
|
const lockKey = `signup-email:${normalizedEmail}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.lockService.withLock(
|
||||||
|
lockKey,
|
||||||
|
async () => {
|
||||||
|
// Check for existing Portal user
|
||||||
|
const existingPortalUser = await this.usersFacade.findByEmailInternal(normalizedEmail);
|
||||||
|
if (existingPortalUser) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "An account already exists with this email. Please log in.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing WHMCS client
|
||||||
|
const existingWhmcs = await this.whmcsDiscovery.findClientByEmail(normalizedEmail);
|
||||||
|
if (existingWhmcs) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
"A billing account already exists with this email. Please use account linking instead.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing SF Account or create new one
|
||||||
|
let sfAccountId: string;
|
||||||
|
let customerNumber: string | undefined;
|
||||||
|
|
||||||
|
const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
|
||||||
|
if (existingSf) {
|
||||||
|
sfAccountId = existingSf.id;
|
||||||
|
customerNumber = existingSf.accountNumber;
|
||||||
|
this.logger.log(
|
||||||
|
{ email: normalizedEmail, sfAccountId },
|
||||||
|
"Using existing SF account for signup"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create new SF Account
|
||||||
|
const { accountId, accountNumber } = await this.salesforceAccountService.createAccount({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: normalizedEmail,
|
||||||
|
phone: phone ?? "",
|
||||||
|
});
|
||||||
|
sfAccountId = accountId;
|
||||||
|
customerNumber = accountNumber;
|
||||||
|
this.logger.log(
|
||||||
|
{ email: normalizedEmail, sfAccountId },
|
||||||
|
"Created new SF account for signup"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create eligibility case
|
||||||
|
const eligibilityRequestId = await this.createEligibilityCase(sfAccountId, address);
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await argon2.hash(password);
|
||||||
|
|
||||||
|
// Create WHMCS client
|
||||||
|
const whmcsClient = await this.whmcsSignup.createClient({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email: normalizedEmail,
|
||||||
|
password,
|
||||||
|
phone,
|
||||||
|
address: {
|
||||||
|
address1: address.address1,
|
||||||
|
...(address.address2 && { address2: address.address2 }),
|
||||||
|
city: address.city,
|
||||||
|
state: address.state ?? "",
|
||||||
|
postcode: address.postcode,
|
||||||
|
country: address.country ?? "Japan",
|
||||||
|
},
|
||||||
|
customerNumber,
|
||||||
|
dateOfBirth,
|
||||||
|
gender,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Portal user and mapping
|
||||||
|
const { userId } = await this.userCreation.createUserWithMapping({
|
||||||
|
email: normalizedEmail,
|
||||||
|
passwordHash,
|
||||||
|
whmcsClientId: whmcsClient.clientId,
|
||||||
|
sfAccountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch fresh user and generate tokens
|
||||||
|
const freshUser = await this.usersFacade.findByIdInternal(userId);
|
||||||
|
if (!freshUser) {
|
||||||
|
throw new Error("Failed to load created user");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, {
|
||||||
|
email: normalizedEmail,
|
||||||
|
whmcsClientId: whmcsClient.clientId,
|
||||||
|
source: "signup_with_eligibility",
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = mapPrismaUserToDomain(freshUser);
|
||||||
|
const tokens = await this.tokenService.generateTokenPair({
|
||||||
|
id: profile.id,
|
||||||
|
email: profile.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Salesforce portal flags
|
||||||
|
await this.updateSalesforcePortalFlags(sfAccountId, whmcsClient.clientId);
|
||||||
|
|
||||||
|
// Invalidate session (fully done, no retry needed)
|
||||||
|
await this.sessionService.invalidate(request.sessionToken);
|
||||||
|
|
||||||
|
// Send welcome email (includes eligibility info)
|
||||||
|
await this.sendWelcomeWithEligibilityEmail(
|
||||||
|
normalizedEmail,
|
||||||
|
firstName,
|
||||||
|
eligibilityRequestId
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
{ email: normalizedEmail, userId, eligibilityRequestId },
|
||||||
|
"Signup with eligibility completed successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
eligibilityRequestId,
|
||||||
|
authResult: {
|
||||||
|
user: profile,
|
||||||
|
tokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ ttlMs: 60_000 } // 60 second lock timeout for the full operation
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
{ error: extractErrorMessage(error), email: normalizedEmail },
|
||||||
|
"Signup with eligibility failed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Don't clear usedStatus on error - partial resources may have been created.
|
||||||
|
// The user must verify their email again to start fresh.
|
||||||
|
// This prevents potential duplicate resource creation on retry.
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Account creation failed. Please verify your email again to retry.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Private Helpers
|
// Private Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -593,6 +842,41 @@ export class GetStartedWorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async sendWelcomeWithEligibilityEmail(
|
||||||
|
email: string,
|
||||||
|
firstName: string,
|
||||||
|
eligibilityRequestId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
|
||||||
|
const templateId = this.config.get<string>("EMAIL_TEMPLATE_WELCOME_WITH_ELIGIBILITY");
|
||||||
|
|
||||||
|
if (templateId) {
|
||||||
|
await this.emailService.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: "Welcome! Your account is ready",
|
||||||
|
templateId,
|
||||||
|
dynamicTemplateData: {
|
||||||
|
firstName,
|
||||||
|
portalUrl: appBase,
|
||||||
|
dashboardUrl: `${appBase}/account/dashboard`,
|
||||||
|
eligibilityRequestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.emailService.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: "Welcome! Your account is ready",
|
||||||
|
html: `
|
||||||
|
<p>Hi ${firstName},</p>
|
||||||
|
<p>Welcome! Your account has been created successfully.</p>
|
||||||
|
<p>We're also checking internet availability at your address. We'll notify you of the results within 1-2 business days.</p>
|
||||||
|
<p>Reference ID: ${eligibilityRequestId}</p>
|
||||||
|
<p>Log in to your dashboard: <a href="${appBase}/account/dashboard">${appBase}/account/dashboard</a></p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async determineAccountStatus(
|
private async determineAccountStatus(
|
||||||
email: string
|
email: string
|
||||||
): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> {
|
): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> {
|
||||||
|
|||||||
@ -134,20 +134,20 @@ export class SignupWorkflowService {
|
|||||||
lastName,
|
lastName,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
company: company ?? undefined,
|
...(company ? { company } : {}),
|
||||||
phone: phone,
|
phone: phone,
|
||||||
address: {
|
address: {
|
||||||
address1: address!.address1!,
|
address1: address!.address1!,
|
||||||
address2: address?.address2 ?? undefined,
|
...(address?.address2 ? { address2: address.address2 } : {}),
|
||||||
city: address!.city!,
|
city: address!.city!,
|
||||||
state: address!.state!,
|
state: address!.state!,
|
||||||
postcode: address!.postcode!,
|
postcode: address!.postcode!,
|
||||||
country: address!.country!,
|
country: address!.country!,
|
||||||
},
|
},
|
||||||
customerNumber: customerNumberForWhmcs,
|
customerNumber: customerNumberForWhmcs,
|
||||||
dateOfBirth,
|
...(dateOfBirth ? { dateOfBirth } : {}),
|
||||||
gender,
|
...(gender ? { gender } : {}),
|
||||||
nationality,
|
...(nationality ? { nationality } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 5: Create user and mapping in database
|
// Step 5: Create user and mapping in database
|
||||||
@ -228,7 +228,7 @@ export class SignupWorkflowService {
|
|||||||
status: PORTAL_STATUS_ACTIVE,
|
status: PORTAL_STATUS_ACTIVE,
|
||||||
source,
|
source,
|
||||||
lastSignedInAt: new Date(),
|
lastSignedInAt: new Date(),
|
||||||
whmcsAccountId,
|
...(whmcsAccountId === undefined ? {} : { whmcsAccountId }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn("Failed to update Salesforce portal flags after signup", {
|
this.logger.warn("Failed to update Salesforce portal flags after signup", {
|
||||||
|
|||||||
@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
export interface SignupAccountSnapshot {
|
export interface SignupAccountSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
Name?: string | null;
|
Name?: string | null | undefined;
|
||||||
WH_Account__c?: string | null;
|
WH_Account__c?: string | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -38,14 +38,14 @@ export interface SignupPreflightResult {
|
|||||||
};
|
};
|
||||||
portal: {
|
portal: {
|
||||||
userExists: boolean;
|
userExists: boolean;
|
||||||
needsPasswordSet?: boolean;
|
needsPasswordSet?: boolean | undefined;
|
||||||
};
|
};
|
||||||
salesforce: {
|
salesforce: {
|
||||||
accountId?: string;
|
accountId?: string | undefined;
|
||||||
alreadyMapped: boolean;
|
alreadyMapped: boolean;
|
||||||
};
|
};
|
||||||
whmcs: {
|
whmcs: {
|
||||||
clientExists: boolean;
|
clientExists: boolean;
|
||||||
clientId?: number;
|
clientId?: number | undefined;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -215,12 +215,12 @@ export class AuthController {
|
|||||||
@Req() req: RequestWithCookies,
|
@Req() req: RequestWithCookies,
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const refreshToken = body.refreshToken ?? req.cookies?.refresh_token;
|
const refreshToken = body.refreshToken ?? req.cookies?.["refresh_token"];
|
||||||
const rawUserAgent = req.headers["user-agent"];
|
const rawUserAgent = req.headers["user-agent"];
|
||||||
const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined;
|
const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined;
|
||||||
const result = await this.authFacade.refreshTokens(refreshToken, {
|
const result = await this.authFacade.refreshTokens(refreshToken, {
|
||||||
deviceId: body.deviceId,
|
deviceId: body.deviceId ?? undefined,
|
||||||
userAgent,
|
userAgent: userAgent ?? undefined,
|
||||||
});
|
});
|
||||||
this.setAuthCookies(res, result.tokens);
|
this.setAuthCookies(res, result.tokens);
|
||||||
return { user: result.user, session: this.toSession(result.tokens) };
|
return { user: result.user, session: this.toSession(result.tokens) };
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import {
|
|||||||
completeAccountRequestSchema,
|
completeAccountRequestSchema,
|
||||||
maybeLaterRequestSchema,
|
maybeLaterRequestSchema,
|
||||||
maybeLaterResponseSchema,
|
maybeLaterResponseSchema,
|
||||||
|
signupWithEligibilityRequestSchema,
|
||||||
|
signupWithEligibilityResponseSchema,
|
||||||
} from "@customer-portal/domain/get-started";
|
} from "@customer-portal/domain/get-started";
|
||||||
|
|
||||||
import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js";
|
import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js";
|
||||||
@ -34,6 +36,8 @@ class GuestEligibilityResponseDto extends createZodDto(guestEligibilityResponseS
|
|||||||
class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {}
|
class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {}
|
||||||
class MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {}
|
class MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {}
|
||||||
class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {}
|
class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {}
|
||||||
|
class SignupWithEligibilityRequestDto extends createZodDto(signupWithEligibilityRequestSchema) {}
|
||||||
|
class SignupWithEligibilityResponseDto extends createZodDto(signupWithEligibilityResponseSchema) {}
|
||||||
|
|
||||||
const ACCESS_COOKIE_PATH = "/api";
|
const ACCESS_COOKIE_PATH = "/api";
|
||||||
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
|
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
|
||||||
@ -177,7 +181,7 @@ export class GetStartedController {
|
|||||||
|
|
||||||
res.cookie("access_token", result.tokens.accessToken, {
|
res.cookie("access_token", result.tokens.accessToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env["NODE_ENV"] === "production",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
path: ACCESS_COOKIE_PATH,
|
path: ACCESS_COOKIE_PATH,
|
||||||
maxAge: calculateCookieMaxAge(accessExpires),
|
maxAge: calculateCookieMaxAge(accessExpires),
|
||||||
@ -185,7 +189,7 @@ export class GetStartedController {
|
|||||||
|
|
||||||
res.cookie("refresh_token", result.tokens.refreshToken, {
|
res.cookie("refresh_token", result.tokens.refreshToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env["NODE_ENV"] === "production",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
path: REFRESH_COOKIE_PATH,
|
path: REFRESH_COOKIE_PATH,
|
||||||
maxAge: calculateCookieMaxAge(refreshExpires),
|
maxAge: calculateCookieMaxAge(refreshExpires),
|
||||||
@ -200,4 +204,65 @@ export class GetStartedController {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full signup with eligibility check (inline flow)
|
||||||
|
* Creates SF Account + Case + WHMCS + Portal in one operation
|
||||||
|
*
|
||||||
|
* Used when user clicks "Create Account" on the eligibility check page.
|
||||||
|
* This is the primary signup path - creates all accounts at once after OTP verification.
|
||||||
|
*
|
||||||
|
* Returns auth tokens (sets httpOnly cookies)
|
||||||
|
*
|
||||||
|
* Rate limit: 5 per 15 minutes per IP
|
||||||
|
*/
|
||||||
|
@Public()
|
||||||
|
@Post("signup-with-eligibility")
|
||||||
|
@HttpCode(200)
|
||||||
|
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||||||
|
@RateLimit({ limit: 5, ttl: 900 })
|
||||||
|
async signupWithEligibility(
|
||||||
|
@Body() body: SignupWithEligibilityRequestDto,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
): Promise<SignupWithEligibilityResponseDto | { user: unknown; session: unknown }> {
|
||||||
|
const result = await this.workflow.signupWithEligibility(body);
|
||||||
|
|
||||||
|
if (!result.success || !result.authResult) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: result.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set auth cookies (same pattern as complete-account)
|
||||||
|
const accessExpires = result.authResult.tokens.expiresAt;
|
||||||
|
const refreshExpires = result.authResult.tokens.refreshExpiresAt;
|
||||||
|
|
||||||
|
res.cookie("access_token", result.authResult.tokens.accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env["NODE_ENV"] === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: ACCESS_COOKIE_PATH,
|
||||||
|
maxAge: calculateCookieMaxAge(accessExpires),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.cookie("refresh_token", result.authResult.tokens.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env["NODE_ENV"] === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: REFRESH_COOKIE_PATH,
|
||||||
|
maxAge: calculateCookieMaxAge(refreshExpires),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
eligibilityRequestId: result.eligibilityRequestId,
|
||||||
|
user: result.authResult.user,
|
||||||
|
session: {
|
||||||
|
expiresAt: accessExpires,
|
||||||
|
refreshExpiresAt: refreshExpires,
|
||||||
|
tokenType: TOKEN_TYPE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,8 +11,8 @@ export class LocalAuthGuard implements CanActivate {
|
|||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
const body = (request.body ?? {}) as Record<string, unknown>;
|
const body = (request.body ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
const email = typeof body.email === "string" ? body.email : "";
|
const email = typeof body["email"] === "string" ? body["email"] : "";
|
||||||
const password = typeof body.password === "string" ? body.password : "";
|
const password = typeof body["password"] === "string" ? body["password"] : "";
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
throw new UnauthorizedException("Invalid credentials");
|
throw new UnauthorizedException("Invalid credentials");
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export const parseJwtExpiry = (expiresIn: string | number | undefined | null): n
|
|||||||
|
|
||||||
const unit = trimmed.slice(-1);
|
const unit = trimmed.slice(-1);
|
||||||
const valuePortion = trimmed.slice(0, -1);
|
const valuePortion = trimmed.slice(0, -1);
|
||||||
const parsedValue = parseInt(valuePortion, 10);
|
const parsedValue = Number.parseInt(valuePortion, 10);
|
||||||
|
|
||||||
const toSeconds = (multiplier: number) => {
|
const toSeconds = (multiplier: number) => {
|
||||||
if (Number.isNaN(parsedValue) || parsedValue <= 0) {
|
if (Number.isNaN(parsedValue) || parsedValue <= 0) {
|
||||||
|
|||||||
@ -24,7 +24,7 @@ const pickFirstStringHeader = (value: unknown): string | undefined => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const resolveAuthorizationHeader = (headers: RequestHeadersLike): string | undefined => {
|
export const resolveAuthorizationHeader = (headers: RequestHeadersLike): string | undefined => {
|
||||||
const raw = headers?.authorization;
|
const raw = headers?.["authorization"];
|
||||||
return pickFirstStringHeader(raw);
|
return pickFirstStringHeader(raw);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -41,6 +41,6 @@ export const extractAccessTokenFromRequest = (request: RequestWithCookies): stri
|
|||||||
const headerToken = extractBearerToken(request.headers);
|
const headerToken = extractBearerToken(request.headers);
|
||||||
if (headerToken) return headerToken;
|
if (headerToken) return headerToken;
|
||||||
|
|
||||||
const cookieToken = request.cookies?.access_token;
|
const cookieToken = request.cookies?.["access_token"];
|
||||||
return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined;
|
return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export class InvoiceRetrievalService {
|
|||||||
const invoiceList = await this.whmcsInvoiceService.getInvoices(whmcsClientId, userId, {
|
const invoiceList = await this.whmcsInvoiceService.getInvoices(whmcsClientId, userId, {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
status,
|
...(status !== undefined && { status }),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Retrieved ${invoiceList.invoices.length} invoices for user ${userId}`, {
|
this.logger.log(`Retrieved ${invoiceList.invoices.length} invoices for user ${userId}`, {
|
||||||
@ -100,7 +100,7 @@ export class InvoiceRetrievalService {
|
|||||||
const queryStatus = status as "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
|
const queryStatus = status as "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
|
||||||
|
|
||||||
return withErrorHandling(
|
return withErrorHandling(
|
||||||
() => this.getInvoices(userId, { page, limit, status: queryStatus }),
|
async () => this.getInvoices(userId, { page, limit, status: queryStatus }),
|
||||||
this.logger,
|
this.logger,
|
||||||
{
|
{
|
||||||
context: `Get ${status} invoices for user ${userId}`,
|
context: `Get ${status} invoices for user ${userId}`,
|
||||||
|
|||||||
@ -20,10 +20,10 @@ export class HealthController {
|
|||||||
// Database check
|
// Database check
|
||||||
try {
|
try {
|
||||||
await this.prisma.$queryRaw`SELECT 1`;
|
await this.prisma.$queryRaw`SELECT 1`;
|
||||||
checks.database = "ok";
|
checks["database"] = "ok";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error({ error }, "Database health check failed");
|
this.logger.error({ error }, "Database health check failed");
|
||||||
checks.database = "fail";
|
checks["database"] = "fail";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache check
|
// Cache check
|
||||||
@ -31,11 +31,11 @@ export class HealthController {
|
|||||||
const key = "health:check";
|
const key = "health:check";
|
||||||
await this.cache.set(key, { ok: true }, 5);
|
await this.cache.set(key, { ok: true }, 5);
|
||||||
const value = await this.cache.get<{ ok: boolean }>(key);
|
const value = await this.cache.get<{ ok: boolean }>(key);
|
||||||
checks.cache = value?.ok ? "ok" : "fail";
|
checks["cache"] = value?.ok ? "ok" : "fail";
|
||||||
await this.cache.del(key);
|
await this.cache.del(key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error({ error }, "Cache health check failed");
|
this.logger.error({ error }, "Cache health check failed");
|
||||||
checks.cache = "fail";
|
checks["cache"] = "fail";
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = Object.values(checks).every(v => v === "ok") ? "ok" : "degraded";
|
const status = Object.values(checks).every(v => v === "ok") ? "ok" : "degraded";
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export class MappingCacheService {
|
|||||||
if (mapping.sfAccountId) {
|
if (mapping.sfAccountId) {
|
||||||
keys.push(this.buildCacheKey("sfAccountId", mapping.sfAccountId));
|
keys.push(this.buildCacheKey("sfAccountId", mapping.sfAccountId));
|
||||||
}
|
}
|
||||||
await Promise.all(keys.map(key => this.cacheService.del(key)));
|
await Promise.all(keys.map(async key => this.cacheService.del(key)));
|
||||||
this.logger.debug(`Deleted mapping cache for user ${mapping.userId}`);
|
this.logger.debug(`Deleted mapping cache for user ${mapping.userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export interface UserIdMapping {
|
|||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
whmcsClientId: number;
|
whmcsClientId: number;
|
||||||
sfAccountId?: string | null;
|
sfAccountId?: string | null | undefined;
|
||||||
createdAt: IsoDateTimeString | Date;
|
createdAt: IsoDateTimeString | Date;
|
||||||
updatedAt: IsoDateTimeString | Date;
|
updatedAt: IsoDateTimeString | Date;
|
||||||
}
|
}
|
||||||
@ -18,12 +18,12 @@ export interface UserIdMapping {
|
|||||||
export interface CreateMappingRequest {
|
export interface CreateMappingRequest {
|
||||||
userId: string;
|
userId: string;
|
||||||
whmcsClientId: number;
|
whmcsClientId: number;
|
||||||
sfAccountId?: string;
|
sfAccountId?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMappingRequest {
|
export interface UpdateMappingRequest {
|
||||||
whmcsClientId?: number;
|
whmcsClientId?: number | undefined;
|
||||||
sfAccountId?: string;
|
sfAccountId?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -98,10 +98,11 @@ export function validateDeletion(
|
|||||||
* The schema handles validation; this is purely for data cleanup.
|
* The schema handles validation; this is purely for data cleanup.
|
||||||
*/
|
*/
|
||||||
export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
|
export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
|
||||||
|
const trimmedSfAccountId = request.sfAccountId?.trim();
|
||||||
return {
|
return {
|
||||||
userId: request.userId?.trim(),
|
userId: request.userId?.trim(),
|
||||||
whmcsClientId: request.whmcsClientId,
|
whmcsClientId: request.whmcsClientId,
|
||||||
sfAccountId: request.sfAccountId?.trim() || undefined,
|
...(trimmedSfAccountId ? { sfAccountId: trimmedSfAccountId } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,14 +113,15 @@ export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMapp
|
|||||||
* The schema handles validation; this is purely for data cleanup.
|
* The schema handles validation; this is purely for data cleanup.
|
||||||
*/
|
*/
|
||||||
export function sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest {
|
export function sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest {
|
||||||
const sanitized: Partial<UpdateMappingRequest> = {};
|
const sanitized: UpdateMappingRequest = {};
|
||||||
|
|
||||||
if (request.whmcsClientId !== undefined) {
|
if (request.whmcsClientId !== undefined) {
|
||||||
sanitized.whmcsClientId = request.whmcsClientId;
|
sanitized.whmcsClientId = request.whmcsClientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.sfAccountId !== undefined) {
|
const trimmedSfAccountId = request.sfAccountId?.trim();
|
||||||
sanitized.sfAccountId = request.sfAccountId?.trim() || undefined;
|
if (trimmedSfAccountId) {
|
||||||
|
sanitized.sfAccountId = trimmedSfAccountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized;
|
return sanitized;
|
||||||
|
|||||||
@ -80,7 +80,13 @@ export class MappingsService {
|
|||||||
|
|
||||||
let created;
|
let created;
|
||||||
try {
|
try {
|
||||||
created = await this.prisma.idMapping.create({ data: sanitizedRequest });
|
// Convert undefined to null for Prisma compatibility
|
||||||
|
const prismaData = {
|
||||||
|
userId: sanitizedRequest.userId,
|
||||||
|
whmcsClientId: sanitizedRequest.whmcsClientId,
|
||||||
|
sfAccountId: sanitizedRequest.sfAccountId ?? null,
|
||||||
|
};
|
||||||
|
created = await this.prisma.idMapping.create({ data: prismaData });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = extractErrorMessage(e);
|
const msg = extractErrorMessage(e);
|
||||||
if (msg.includes("P2002") || /unique/i.test(msg)) {
|
if (msg.includes("P2002") || /unique/i.test(msg)) {
|
||||||
@ -245,9 +251,18 @@ export class MappingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert undefined to null for Prisma compatibility
|
||||||
|
const prismaUpdateData: Prisma.IdMappingUpdateInput = {
|
||||||
|
...(sanitizedUpdates.whmcsClientId !== undefined && {
|
||||||
|
whmcsClientId: sanitizedUpdates.whmcsClientId,
|
||||||
|
}),
|
||||||
|
...(sanitizedUpdates.sfAccountId !== undefined && {
|
||||||
|
sfAccountId: sanitizedUpdates.sfAccountId ?? null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
const updated = await this.prisma.idMapping.update({
|
const updated = await this.prisma.idMapping.update({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
data: sanitizedUpdates,
|
data: prismaUpdateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newMapping = mapPrismaMappingToDomain(updated);
|
const newMapping = mapPrismaMappingToDomain(updated);
|
||||||
|
|||||||
@ -186,32 +186,32 @@ export class MeStatusService {
|
|||||||
|
|
||||||
// Priority 3: pending orders
|
// Priority 3: pending orders
|
||||||
if (orders && orders.length > 0) {
|
if (orders && orders.length > 0) {
|
||||||
const pendingOrders = orders.filter(
|
const pendingOrder = orders.find(
|
||||||
o =>
|
o =>
|
||||||
o.status === "Draft" ||
|
o.status === "Draft" ||
|
||||||
o.status === "Pending" ||
|
o.status === "Pending" ||
|
||||||
(o.status === "Activated" && o.activationStatus !== "Completed")
|
(o.status === "Activated" && o.activationStatus !== "Completed")
|
||||||
);
|
);
|
||||||
|
|
||||||
if (pendingOrders.length > 0) {
|
const firstPendingOrder = pendingOrder;
|
||||||
const order = pendingOrders[0];
|
if (firstPendingOrder) {
|
||||||
const statusText =
|
const statusText =
|
||||||
order.status === "Pending"
|
firstPendingOrder.status === "Pending"
|
||||||
? "awaiting review"
|
? "awaiting review"
|
||||||
: order.status === "Draft"
|
: firstPendingOrder.status === "Draft"
|
||||||
? "in draft"
|
? "in draft"
|
||||||
: "being activated";
|
: "being activated";
|
||||||
|
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: `order-${order.id}`,
|
id: `order-${firstPendingOrder.id}`,
|
||||||
priority: 3,
|
priority: 3,
|
||||||
type: "order",
|
type: "order",
|
||||||
title: "Order in progress",
|
title: "Order in progress",
|
||||||
description: `${order.orderType || "Your"} order is ${statusText}`,
|
description: `${firstPendingOrder.orderType || "Your"} order is ${statusText}`,
|
||||||
actionLabel: "View details",
|
actionLabel: "View details",
|
||||||
detailHref: `/account/orders/${order.id}`,
|
detailHref: `/account/orders/${firstPendingOrder.id}`,
|
||||||
tone: "info",
|
tone: "info",
|
||||||
metadata: { orderId: order.id },
|
metadata: { orderId: firstPendingOrder.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,12 +36,12 @@ const NOTIFICATION_DEDUPE_WINDOW_HOURS: Partial<Record<NotificationTypeValue, nu
|
|||||||
export interface CreateNotificationParams {
|
export interface CreateNotificationParams {
|
||||||
userId: string;
|
userId: string;
|
||||||
type: NotificationTypeValue;
|
type: NotificationTypeValue;
|
||||||
title?: string;
|
title?: string | undefined;
|
||||||
message?: string;
|
message?: string | undefined;
|
||||||
actionUrl?: string;
|
actionUrl?: string | undefined;
|
||||||
actionLabel?: string;
|
actionLabel?: string | undefined;
|
||||||
source?: NotificationSourceValue;
|
source?: NotificationSourceValue | undefined;
|
||||||
sourceId?: string;
|
sourceId?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@ -109,13 +109,13 @@ export class OrdersController {
|
|||||||
req.user?.id
|
req.user?.id
|
||||||
);
|
);
|
||||||
|
|
||||||
const uniqueSkus = Array.from(
|
const uniqueSkus = [
|
||||||
new Set(
|
...new Set(
|
||||||
cart.items
|
cart.items
|
||||||
.map(item => item.sku)
|
.map(item => item.sku)
|
||||||
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
|
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
|
||||||
)
|
),
|
||||||
);
|
];
|
||||||
|
|
||||||
if (uniqueSkus.length === 0) {
|
if (uniqueSkus.length === 0) {
|
||||||
throw new NotFoundException("Checkout session contains no items");
|
throw new NotFoundException("Checkout session contains no items");
|
||||||
|
|||||||
@ -47,6 +47,6 @@ export class ProvisioningQueueService {
|
|||||||
|
|
||||||
async depth(): Promise<number> {
|
async depth(): Promise<number> {
|
||||||
const counts = await this.queue.getJobCounts("waiting", "active", "delayed");
|
const counts = await this.queue.getJobCounts("waiting", "active", "delayed");
|
||||||
return (counts.waiting || 0) + (counts.active || 0) + (counts.delayed || 0);
|
return (counts["waiting"] || 0) + (counts["active"] || 0) + (counts["delayed"] || 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -448,7 +448,11 @@ export class CheckoutService {
|
|||||||
return { fee: defaultFee, autoAdded: true };
|
return { fee: defaultFee, autoAdded: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { fee: activationFees[0], autoAdded: true };
|
const firstFee = activationFees[0];
|
||||||
|
if (!firstFee) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { fee: firstFee, autoAdded: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -460,14 +464,14 @@ export class CheckoutService {
|
|||||||
// Handle various addon selection formats
|
// Handle various addon selection formats
|
||||||
if (selections.addonSku) refs.add(selections.addonSku);
|
if (selections.addonSku) refs.add(selections.addonSku);
|
||||||
if (selections.addons) {
|
if (selections.addons) {
|
||||||
selections.addons
|
for (const value of selections.addons
|
||||||
.split(",")
|
.split(",")
|
||||||
.map(value => value.trim())
|
.map(value => value.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean))
|
||||||
.forEach(value => refs.add(value));
|
refs.add(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(refs);
|
return [...refs];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -124,7 +124,7 @@ export class OrderBuilder {
|
|||||||
try {
|
try {
|
||||||
const profile = await this.usersFacade.getProfile(userId);
|
const profile = await this.usersFacade.getProfile(userId);
|
||||||
const address = profile.address;
|
const address = profile.address;
|
||||||
const orderAddress = (body.configurations as Record<string, unknown>)?.address as
|
const orderAddress = (body.configurations as Record<string, unknown>)?.["address"] as
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
const addressChanged = !!orderAddress;
|
const addressChanged = !!orderAddress;
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export class OrderFulfillmentErrorService {
|
|||||||
* Get user-friendly error message for external consumption
|
* Get user-friendly error message for external consumption
|
||||||
* Ensures no sensitive information is exposed
|
* Ensures no sensitive information is exposed
|
||||||
*/
|
*/
|
||||||
getUserFriendlyMessage(error: unknown, errorCode: OrderFulfillmentErrorCode): string {
|
getUserFriendlyMessage(_error: unknown, errorCode: OrderFulfillmentErrorCode): string {
|
||||||
switch (errorCode) {
|
switch (errorCode) {
|
||||||
case ORDER_FULFILLMENT_ERROR_CODE.PAYMENT_METHOD_MISSING:
|
case ORDER_FULFILLMENT_ERROR_CODE.PAYMENT_METHOD_MISSING:
|
||||||
return "Payment method missing - please add a payment method before fulfillment";
|
return "Payment method missing - please add a payment method before fulfillment";
|
||||||
|
|||||||
@ -31,18 +31,18 @@ type WhmcsOrderItemMappingResult = ReturnType<typeof mapOrderToWhmcsItems>;
|
|||||||
export interface OrderFulfillmentStep {
|
export interface OrderFulfillmentStep {
|
||||||
step: string;
|
step: string;
|
||||||
status: "pending" | "in_progress" | "completed" | "failed";
|
status: "pending" | "in_progress" | "completed" | "failed";
|
||||||
startedAt?: Date;
|
startedAt?: Date | undefined;
|
||||||
completedAt?: Date;
|
completedAt?: Date | undefined;
|
||||||
error?: string;
|
error?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderFulfillmentContext {
|
export interface OrderFulfillmentContext {
|
||||||
sfOrderId: string;
|
sfOrderId: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
validation: OrderFulfillmentValidationResult | null;
|
validation: OrderFulfillmentValidationResult | null;
|
||||||
orderDetails?: OrderDetails;
|
orderDetails?: OrderDetails | undefined;
|
||||||
mappingResult?: WhmcsOrderItemMappingResult;
|
mappingResult?: WhmcsOrderItemMappingResult | undefined;
|
||||||
whmcsResult?: WhmcsOrderResult;
|
whmcsResult?: WhmcsOrderResult | undefined;
|
||||||
steps: OrderFulfillmentStep[];
|
steps: OrderFulfillmentStep[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,7 +92,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
validation: null,
|
validation: null,
|
||||||
steps: this.initializeSteps(
|
steps: this.initializeSteps(
|
||||||
typeof payload.orderType === "string" ? payload.orderType : "Unknown"
|
typeof payload["orderType"] === "string" ? payload["orderType"] : "Unknown"
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -105,6 +105,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
// Step 1: Validation (no rollback needed)
|
// Step 1: Validation (no rollback needed)
|
||||||
this.updateStepStatus(context, "validation", "in_progress");
|
this.updateStepStatus(context, "validation", "in_progress");
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions
|
||||||
context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest(
|
context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest(
|
||||||
sfOrderId,
|
sfOrderId,
|
||||||
idempotencyKey
|
idempotencyKey
|
||||||
@ -146,6 +147,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions
|
||||||
context.orderDetails = orderDetails;
|
context.orderDetails = orderDetails;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to get order details", {
|
this.logger.error("Failed to get order details", {
|
||||||
@ -197,7 +199,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
{
|
{
|
||||||
id: "order_details",
|
id: "order_details",
|
||||||
description: "Retain order details in context",
|
description: "Retain order details in context",
|
||||||
execute: this.createTrackedStep(context, "order_details", () =>
|
execute: this.createTrackedStep(context, "order_details", async () =>
|
||||||
Promise.resolve(context.orderDetails)
|
Promise.resolve(context.orderDetails)
|
||||||
),
|
),
|
||||||
critical: false,
|
critical: false,
|
||||||
@ -205,7 +207,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
{
|
{
|
||||||
id: "mapping",
|
id: "mapping",
|
||||||
description: "Map OrderItems to WHMCS format",
|
description: "Map OrderItems to WHMCS format",
|
||||||
execute: this.createTrackedStep(context, "mapping", () => {
|
execute: this.createTrackedStep(context, "mapping", async () => {
|
||||||
if (!context.orderDetails) {
|
if (!context.orderDetails) {
|
||||||
return Promise.reject(new Error("Order details are required for mapping"));
|
return Promise.reject(new Error("Order details are required for mapping"));
|
||||||
}
|
}
|
||||||
@ -263,7 +265,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
whmcsCreateResult = result;
|
whmcsCreateResult = result;
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
rollback: () => {
|
rollback: async () => {
|
||||||
if (whmcsCreateResult?.orderId) {
|
if (whmcsCreateResult?.orderId) {
|
||||||
// Note: WHMCS doesn't have an automated cancel API
|
// Note: WHMCS doesn't have an automated cancel API
|
||||||
// Manual intervention required for order cleanup
|
// Manual intervention required for order cleanup
|
||||||
@ -297,7 +299,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId);
|
await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId);
|
||||||
return { orderId: whmcsCreateResult.orderId };
|
return { orderId: whmcsCreateResult.orderId };
|
||||||
}),
|
}),
|
||||||
rollback: () => {
|
rollback: async () => {
|
||||||
if (whmcsCreateResult?.orderId) {
|
if (whmcsCreateResult?.orderId) {
|
||||||
// Note: WHMCS doesn't have an automated cancel API for accepted orders
|
// Note: WHMCS doesn't have an automated cancel API for accepted orders
|
||||||
// Manual intervention required for service termination
|
// Manual intervention required for service termination
|
||||||
@ -320,7 +322,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
description: "SIM-specific fulfillment (if applicable)",
|
description: "SIM-specific fulfillment (if applicable)",
|
||||||
execute: this.createTrackedStep(context, "sim_fulfillment", async () => {
|
execute: this.createTrackedStep(context, "sim_fulfillment", async () => {
|
||||||
if (context.orderDetails?.orderType === "SIM") {
|
if (context.orderDetails?.orderType === "SIM") {
|
||||||
const configurations = this.extractConfigurations(payload.configurations);
|
const configurations = this.extractConfigurations(payload["configurations"]);
|
||||||
await this.simFulfillmentService.fulfillSimOrder({
|
await this.simFulfillmentService.fulfillSimOrder({
|
||||||
orderDetails: context.orderDetails,
|
orderDetails: context.orderDetails,
|
||||||
configurations,
|
configurations,
|
||||||
@ -444,7 +446,9 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update context with results
|
// Update context with results
|
||||||
|
// eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions
|
||||||
context.mappingResult = mappingResult;
|
context.mappingResult = mappingResult;
|
||||||
|
// eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions
|
||||||
context.whmcsResult = whmcsCreateResult;
|
context.whmcsResult = whmcsCreateResult;
|
||||||
|
|
||||||
this.logger.log("Transactional fulfillment completed successfully", {
|
this.logger.log("Transactional fulfillment completed successfully", {
|
||||||
@ -584,8 +588,8 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode)
|
this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode)
|
||||||
)
|
)
|
||||||
.toString()
|
.toString()
|
||||||
.substring(0, 60),
|
.slice(0, 60),
|
||||||
Activation_Error_Message__c: userMessage?.substring(0, 255),
|
Activation_Error_Message__c: userMessage?.slice(0, 255),
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.salesforceService.updateOrder(updates as { Id: string; [key: string]: unknown });
|
await this.salesforceService.updateOrder(updates as { Id: string; [key: string]: unknown });
|
||||||
@ -608,8 +612,8 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
getFulfillmentSummary(context: OrderFulfillmentContext): {
|
getFulfillmentSummary(context: OrderFulfillmentContext): {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
status: "Already Fulfilled" | "Fulfilled" | "Failed";
|
status: "Already Fulfilled" | "Fulfilled" | "Failed";
|
||||||
whmcsOrderId?: string;
|
whmcsOrderId?: string | undefined;
|
||||||
whmcsServiceIds?: number[];
|
whmcsServiceIds?: number[] | undefined;
|
||||||
message: string;
|
message: string;
|
||||||
steps: OrderFulfillmentStep[];
|
steps: OrderFulfillmentStep[];
|
||||||
} {
|
} {
|
||||||
@ -617,21 +621,24 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
const failedStep = context.steps.find((s: OrderFulfillmentStep) => s.status === "failed");
|
const failedStep = context.steps.find((s: OrderFulfillmentStep) => s.status === "failed");
|
||||||
|
|
||||||
if (context.validation?.isAlreadyProvisioned) {
|
if (context.validation?.isAlreadyProvisioned) {
|
||||||
|
const whmcsOrderId = context.validation.whmcsOrderId;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
status: "Already Fulfilled",
|
status: "Already Fulfilled",
|
||||||
whmcsOrderId: context.validation.whmcsOrderId,
|
...(whmcsOrderId !== undefined && { whmcsOrderId }),
|
||||||
message: "Order was already fulfilled in WHMCS",
|
message: "Order was already fulfilled in WHMCS",
|
||||||
steps: context.steps,
|
steps: context.steps,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
|
const whmcsOrderId = context.whmcsResult?.orderId.toString();
|
||||||
|
const whmcsServiceIds = context.whmcsResult?.serviceIds;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
status: "Fulfilled",
|
status: "Fulfilled",
|
||||||
whmcsOrderId: context.whmcsResult?.orderId.toString(),
|
...(whmcsOrderId !== undefined && { whmcsOrderId }),
|
||||||
whmcsServiceIds: context.whmcsResult?.serviceIds,
|
...(whmcsServiceIds !== undefined && { whmcsServiceIds }),
|
||||||
message: "Order fulfilled successfully in WHMCS",
|
message: "Order fulfilled successfully in WHMCS",
|
||||||
steps: context.steps,
|
steps: context.steps,
|
||||||
};
|
};
|
||||||
@ -640,7 +647,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
status: "Failed",
|
status: "Failed",
|
||||||
message: failedStep?.error || "Fulfillment failed",
|
message: failedStep?.error ?? "Fulfillment failed",
|
||||||
steps: context.steps,
|
steps: context.steps,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -658,13 +665,17 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
if (status === "in_progress") {
|
if (status === "in_progress") {
|
||||||
step.status = "in_progress";
|
step.status = "in_progress";
|
||||||
step.startedAt = timestamp;
|
step.startedAt = timestamp;
|
||||||
step.error = undefined;
|
delete step.error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
step.status = status;
|
step.status = status;
|
||||||
step.completedAt = timestamp;
|
step.completedAt = timestamp;
|
||||||
step.error = status === "failed" ? error : undefined;
|
if (status === "failed" && error !== undefined) {
|
||||||
|
step.error = error;
|
||||||
|
} else {
|
||||||
|
delete step.error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createTrackedStep<TResult>(
|
private createTrackedStep<TResult>(
|
||||||
|
|||||||
@ -4,9 +4,11 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi
|
|||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
|
import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
|
||||||
import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers";
|
import type {
|
||||||
|
OrderFulfillmentValidationResult,
|
||||||
|
SalesforceOrderRecord,
|
||||||
|
} from "@customer-portal/domain/orders/providers";
|
||||||
import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
|
import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
|
||||||
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
|
|
||||||
import { PaymentValidatorService } from "./payment-validator.service.js";
|
import { PaymentValidatorService } from "./payment-validator.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user