refactor: enterprise-grade cleanup of BFF and domain packages

Comprehensive refactoring across 70 files (net -298 lines) improving
type safety, error handling, and code organization:

- Replace .passthrough()/.catchall(z.unknown()) with .strip() in all Zod schemas
- Tighten Record<string, unknown> to bounded union types where possible
- Replace throw new Error with domain-specific exceptions (OrderException,
  FulfillmentException, WhmcsOperationException, SalesforceOperationException, etc.)
- Split AuthTokenService (625 lines) into TokenGeneratorService and
  TokenRefreshService with thin orchestrator
- Deduplicate FreebitClientService with shared makeRequest() method
- Add typed interfaces to WHMCS facade, order service, and fulfillment mapper
- Externalize hardcoded config values to ConfigService with env fallbacks
- Consolidate duplicate billing cycle enums into shared billingCycleSchema
- Standardize logger usage (nestjs-pino @Inject(Logger) everywhere)
- Move shared WHMCS number coercion helpers to whmcs-utils/schema.ts
This commit is contained in:
barsa 2026-02-24 19:05:30 +09:00
parent f4918b8a79
commit b206de8dba
72 changed files with 1287 additions and 989 deletions

View File

@ -40,8 +40,3 @@ export const getDevAuthConfig = (): DevAuthConfig => {
skipOtp: isDevelopment && process.env["SKIP_OTP"] === "true", skipOtp: isDevelopment && process.env["SKIP_OTP"] === "true",
}; };
}; };
/**
* @deprecated Use getDevAuthConfig() instead to ensure env vars are read after ConfigModule loads
*/
export const devAuthConfig = getDevAuthConfig();

View File

@ -7,7 +7,7 @@
* Uses Redis SET NX PX pattern for atomic lock acquisition with TTL. * Uses Redis SET NX PX pattern for atomic lock acquisition with TTL.
*/ */
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import type { Redis } from "ioredis"; import type { Redis } from "ioredis";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@ -99,7 +99,7 @@ export class DistributedLockService {
const lock = await this.acquire(key, options); const lock = await this.acquire(key, options);
if (!lock) { if (!lock) {
throw new Error(`Unable to acquire lock for key: ${key}`); throw new InternalServerErrorException(`Unable to acquire lock for key: ${key}`);
} }
try { try {

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { TransactionService, type TransactionOperation } from "./transaction.service.js"; import { TransactionService, type TransactionOperation } from "./transaction.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -331,7 +331,7 @@ export class DistributedTransactionService {
); );
if (!externalResult.success) { if (!externalResult.success) {
throw new Error(externalResult.error || "External operations failed"); throw new InternalServerErrorException(externalResult.error || "External operations failed");
} }
this.logger.debug(`Executing database operations [${transactionId}]`); this.logger.debug(`Executing database operations [${transactionId}]`);
@ -360,7 +360,7 @@ export class DistributedTransactionService {
}); });
if (!result.success) { if (!result.success) {
throw new Error(result.error || "Database transaction failed"); throw new InternalServerErrorException(result.error || "Database transaction failed");
} }
return result.data!; return result.data!;
@ -503,7 +503,9 @@ export class DistributedTransactionService {
} }
} }
throw new Error(`Step ${step.id} failed after ${totalAttempts} attempts`); throw new InternalServerErrorException(
`Step ${step.id} failed after ${totalAttempts} attempts`
);
} }
private generateTransactionId(): string { private generateTransactionId(): string {

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq"; import { InjectQueue } from "@nestjs/bullmq";
import { Queue, Job } from "bullmq"; import { Queue, Job } from "bullmq";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
@ -69,7 +69,7 @@ export class EmailQueueService {
error: errorMessage, error: errorMessage,
}); });
throw new Error(`Failed to queue email: ${errorMessage}`); throw new InternalServerErrorException(`Failed to queue email: ${errorMessage}`);
} }
} }

View File

@ -24,17 +24,38 @@ export class FreebitClientService {
) {} ) {}
/** /**
* Make an authenticated request to Freebit API with retry logic * Make an authenticated form-encoded request to Freebit API with retry logic
*/ */
async makeAuthenticatedRequest<TResponse extends FreebitResponseBase, TPayload extends object>( async makeAuthenticatedRequest<TResponse extends FreebitResponseBase, TPayload extends object>(
endpoint: string, endpoint: string,
payload: TPayload payload: TPayload
): Promise<TResponse> {
return this.makeRequest<TResponse, TPayload>(endpoint, payload, "form");
}
/**
* Make an authenticated JSON request to Freebit API (for PA05-38, PA05-41, etc.)
*/
async makeAuthenticatedJsonRequest<
TResponse extends FreebitResponseBase,
TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> {
return this.makeRequest<TResponse, TPayload>(endpoint, payload, "json");
}
/**
* Core authenticated request handler shared by form-encoded and JSON variants.
*/
private async makeRequest<TResponse extends FreebitResponseBase, TPayload extends object>(
endpoint: string,
payload: TPayload,
contentType: "form" | "json"
): Promise<TResponse> { ): Promise<TResponse> {
const config = this.authService.getConfig(); const config = this.authService.getConfig();
const authKey = await this.authService.getAuthKey(); const authKey = await this.authService.getAuthKey();
const url = this.buildUrl(config.baseUrl, endpoint); const url = this.buildUrl(config.baseUrl, endpoint);
const requestPayload = { ...payload, authKey }; const requestPayload = { ...payload, authKey };
const logLabel = contentType === "json" ? "Freebit JSON API" : "Freebit API";
let attempt = 0; let attempt = 0;
try { try {
@ -42,7 +63,7 @@ export class FreebitClientService {
async () => { async () => {
attempt += 1; attempt += 1;
this.logger.debug(`Freebit API request`, { this.logger.debug(`${logLabel} request`, {
url, url,
attempt, attempt,
maxAttempts: config.retryAttempts, maxAttempts: config.retryAttempts,
@ -52,17 +73,27 @@ export class FreebitClientService {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout); const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try { try {
const headers =
contentType === "json"
? { "Content-Type": "application/json" }
: { "Content-Type": "application/x-www-form-urlencoded" };
const body =
contentType === "json"
? JSON.stringify(requestPayload)
: `json=${JSON.stringify(requestPayload)}`;
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers,
body: `json=${JSON.stringify(requestPayload)}`, body,
signal: controller.signal, signal: controller.signal,
}); });
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(`${logLabel} HTTP error`, {
url, url,
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
@ -87,140 +118,12 @@ export class FreebitClientService {
resultCode, resultCode,
statusCode, statusCode,
statusMessage: responseData.status?.message, statusMessage: responseData.status?.message,
...(isProd ? {} : { fullResponse: JSON.stringify(responseData) }),
};
this.logger.error("Freebit API returned error response", errorDetails);
// Also log to console for visibility in dev
if (!isProd) {
console.error("[FREEBIT ERROR]", JSON.stringify(errorDetails, null, 2));
}
throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`,
resultCode,
statusCode,
responseData.status?.message
);
}
this.logger.debug("Freebit API request successful", { url, resultCode });
return responseData;
} finally {
clearTimeout(timeoutId);
}
},
{
maxAttempts: config.retryAttempts,
baseDelayMs: 1000,
maxDelayMs: 10000,
isRetryable: error => {
if (error instanceof FreebitError) {
if (error.isAuthError() && attempt === 1) {
this.logger.warn("Freebit auth error detected, clearing cache and retrying", {
url,
});
this.authService.clearAuthCache();
return true;
}
return error.isRetryable();
}
return RetryableErrors.isTransientError(error);
},
logger: this.logger,
logContext: "Freebit API request",
}
);
// Track successful API call
this.trackApiCall(endpoint, payload, responseData, null).catch((error: unknown) => {
this.logger.debug("Failed to track API call", {
error: error instanceof Error ? error.message : String(error),
});
});
return responseData;
} catch (error: unknown) {
// Track failed API call
this.trackApiCall(endpoint, payload, null, error).catch((trackError: unknown) => {
this.logger.debug("Failed to track API call error", {
error: trackError instanceof Error ? trackError.message : String(trackError),
});
});
throw error;
}
}
/**
* Make an authenticated JSON request to Freebit API (for PA05-38, PA05-41, etc.)
*/
async makeAuthenticatedJsonRequest<
TResponse extends FreebitResponseBase,
TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> {
const config = this.authService.getConfig();
const authKey = await this.authService.getAuthKey();
const url = this.buildUrl(config.baseUrl, endpoint);
// Add authKey to the payload for authentication
const requestPayload = { ...payload, authKey };
let attempt = 0;
// Log request details in dev for debugging
const isProd = process.env["NODE_ENV"] === "production";
if (!isProd) {
this.logger.debug("[FREEBIT JSON API REQUEST]", {
url,
payload: redactForLogs(requestPayload),
});
}
try {
const responseData = await withRetry(
async () => {
attempt += 1;
this.logger.debug("Freebit JSON API request", {
url,
attempt,
maxAttempts: config.retryAttempts,
payload: redactForLogs(requestPayload),
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestPayload),
signal: controller.signal,
});
if (!response.ok) {
throw new FreebitError(
`HTTP ${response.status}: ${response.statusText}`,
response.status.toString()
);
}
const responseData = (await response.json()) as TResponse;
const resultCode = this.normalizeResultCode(responseData.resultCode);
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") {
const isProd = process.env["NODE_ENV"] === "production";
const errorDetails = {
url,
resultCode,
statusCode,
message: responseData.status?.message,
...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }),
attempt, attempt,
}; };
this.logger.error("Freebit JSON API returned error result code", errorDetails); this.logger.error(`${logLabel} returned error response`, errorDetails);
// Always log to console in dev for visibility this.logger.debug({ errorDetails }, `${logLabel} error details`);
if (!isProd) {
console.error("[FREEBIT JSON API ERROR]", JSON.stringify(errorDetails, null, 2));
}
throw new FreebitError( throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`, `API Error: ${responseData.status?.message || "Unknown error"}`,
resultCode, resultCode,
@ -229,7 +132,7 @@ export class FreebitClientService {
); );
} }
this.logger.debug("Freebit JSON API request successful", { url, resultCode }); this.logger.debug(`${logLabel} request successful`, { url, resultCode });
return responseData; return responseData;
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
@ -242,7 +145,7 @@ export class FreebitClientService {
isRetryable: error => { isRetryable: error => {
if (error instanceof FreebitError) { if (error instanceof FreebitError) {
if (error.isAuthError() && attempt === 1) { if (error.isAuthError() && attempt === 1) {
this.logger.warn("Freebit auth error detected, clearing cache and retrying", { this.logger.warn(`${logLabel} auth error detected, clearing cache and retrying`, {
url, url,
}); });
this.authService.clearAuthCache(); this.authService.clearAuthCache();
@ -253,7 +156,7 @@ export class FreebitClientService {
return RetryableErrors.isTransientError(error); return RetryableErrors.isTransientError(error);
}, },
logger: this.logger, logger: this.logger,
logContext: "Freebit JSON API request", logContext: `${logLabel} request`,
} }
); );

View File

@ -1,5 +1,6 @@
import { Injectable, Inject, Optional } from "@nestjs/common"; import { Injectable, Inject, Optional } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type { import type {
FreebitAccountDetailsResponse, FreebitAccountDetailsResponse,
FreebitTrafficInfoResponse, FreebitTrafficInfoResponse,
@ -72,7 +73,7 @@ export class FreebitMapperService {
async mapToSimDetails(response: FreebitAccountDetailsResponse): Promise<SimDetails> { async mapToSimDetails(response: FreebitAccountDetailsResponse): Promise<SimDetails> {
const account = response.responseDatas[0]; const account = response.responseDatas[0];
if (!account) { if (!account) {
throw new Error("No account data in response"); throw new FreebitOperationException("No account data in response");
} }
// Debug: Log raw voice option fields from API response // Debug: Log raw voice option fields from API response
@ -212,7 +213,7 @@ export class FreebitMapperService {
*/ */
mapToSimUsage(response: FreebitTrafficInfoResponse): SimUsage { mapToSimUsage(response: FreebitTrafficInfoResponse): SimUsage {
if (!response.traffic) { if (!response.traffic) {
throw new Error("No traffic data in response"); throw new FreebitOperationException("No traffic data in response");
} }
const todayUsageKb = Number.parseInt(response.traffic.today, 10) || 0; const todayUsageKb = Number.parseInt(response.traffic.today, 10) || 0;
@ -237,7 +238,7 @@ export class FreebitMapperService {
*/ */
mapToSimTopUpHistory(response: FreebitQuotaHistoryResponse, account: string): SimTopUpHistory { mapToSimTopUpHistory(response: FreebitQuotaHistoryResponse, account: string): SimTopUpHistory {
if (!response.quotaHistory) { if (!response.quotaHistory) {
throw new Error("No history data in response"); throw new FreebitOperationException("No history data in response");
} }
return { return {

View File

@ -9,10 +9,16 @@
* JAPAN_POST_CLIENT_SECRET - OAuth client secret * JAPAN_POST_CLIENT_SECRET - OAuth client secret
* *
* Optional Environment Variables: * Optional Environment Variables:
* JAPAN_POST_TIMEOUT - Request timeout in ms (default: 10000) * JAPAN_POST_TIMEOUT - Request timeout in ms (default: 10000)
* JAPAN_POST_DEFAULT_CLIENT_IP - Default client IP for x-forwarded-for (default: 127.0.0.1)
*/ */
import { Injectable, Inject, type OnModuleInit } from "@nestjs/common"; import {
Injectable,
Inject,
InternalServerErrorException,
type OnModuleInit,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -26,6 +32,7 @@ interface JapanPostConfig {
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
timeout: number; timeout: number;
defaultClientIp: string;
} }
interface ConfigValidationError { interface ConfigValidationError {
@ -58,6 +65,8 @@ export class JapanPostConnectionService implements OnModuleInit {
clientId: this.configService.get<string>("JAPAN_POST_CLIENT_ID") || "", clientId: this.configService.get<string>("JAPAN_POST_CLIENT_ID") || "",
clientSecret: this.configService.get<string>("JAPAN_POST_CLIENT_SECRET") || "", clientSecret: this.configService.get<string>("JAPAN_POST_CLIENT_SECRET") || "",
timeout: this.configService.get<number>("JAPAN_POST_TIMEOUT") || 10000, timeout: this.configService.get<number>("JAPAN_POST_TIMEOUT") || 10000,
defaultClientIp:
this.configService.get<string>("JAPAN_POST_DEFAULT_CLIENT_IP") || "127.0.0.1",
}; };
// Validate configuration // Validate configuration
@ -159,7 +168,7 @@ export class JapanPostConnectionService implements OnModuleInit {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-forwarded-for": "127.0.0.1", // Required by API "x-forwarded-for": this.config.defaultClientIp, // Required by API
}, },
body: JSON.stringify({ body: JSON.stringify({
grant_type: "client_credentials", grant_type: "client_credentials",
@ -186,7 +195,7 @@ export class JapanPostConnectionService implements OnModuleInit {
apiMessage: parsedError?.message, apiMessage: parsedError?.message,
hint: this.getErrorHint(response.status, parsedError?.error_code), hint: this.getErrorHint(response.status, parsedError?.error_code),
}); });
throw new Error(`Token request failed: HTTP ${response.status}`); throw new InternalServerErrorException(`Token request failed: HTTP ${response.status}`);
} }
const data = (await response.json()) as JapanPostTokenResponse; const data = (await response.json()) as JapanPostTokenResponse;
@ -213,11 +222,13 @@ export class JapanPostConnectionService implements OnModuleInit {
timeoutMs: this.config.timeout, timeoutMs: this.config.timeout,
durationMs, durationMs,
}); });
throw new Error(`Token request timed out after ${this.config.timeout}ms`); throw new InternalServerErrorException(
`Token request timed out after ${this.config.timeout}ms`
);
} }
// Only log if not already logged above (non-ok response) // Only log if not already logged above (non-ok response)
if (!(error instanceof Error && error.message.startsWith("Token request failed"))) { if (!(error instanceof InternalServerErrorException)) {
this.logger.error("Japan Post token request error", { this.logger.error("Japan Post token request error", {
endpoint: tokenUrl, endpoint: tokenUrl,
error: extractErrorMessage(error), error: extractErrorMessage(error),
@ -298,7 +309,8 @@ export class JapanPostConnectionService implements OnModuleInit {
* @param clientIp - Client IP address for x-forwarded-for header * @param clientIp - Client IP address for x-forwarded-for header
* @returns Raw Japan Post API response * @returns Raw Japan Post API response
*/ */
async searchByZipCode(zipCode: string, clientIp: string = "127.0.0.1"): Promise<unknown> { async searchByZipCode(zipCode: string, clientIp?: string): Promise<unknown> {
const ip = clientIp || this.config.defaultClientIp;
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const controller = new AbortController(); const controller = new AbortController();
@ -314,7 +326,7 @@ export class JapanPostConnectionService implements OnModuleInit {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
"x-forwarded-for": clientIp, "x-forwarded-for": ip,
}, },
signal: controller.signal, signal: controller.signal,
}); });
@ -337,7 +349,7 @@ export class JapanPostConnectionService implements OnModuleInit {
apiMessage: parsedError?.message, apiMessage: parsedError?.message,
hint: this.getErrorHint(response.status, parsedError?.error_code), hint: this.getErrorHint(response.status, parsedError?.error_code),
}); });
throw new Error(`ZIP code search failed: HTTP ${response.status}`); throw new InternalServerErrorException(`ZIP code search failed: HTTP ${response.status}`);
} }
const data = await response.json(); const data = await response.json();
@ -360,11 +372,13 @@ export class JapanPostConnectionService implements OnModuleInit {
timeoutMs: this.config.timeout, timeoutMs: this.config.timeout,
durationMs, durationMs,
}); });
throw new Error(`ZIP search timed out after ${this.config.timeout}ms`); throw new InternalServerErrorException(
`ZIP search timed out after ${this.config.timeout}ms`
);
} }
// Only log if not already logged above (non-ok response) // Only log if not already logged above (non-ok response)
if (!(error instanceof Error && error.message.startsWith("ZIP code search failed"))) { if (!(error instanceof InternalServerErrorException)) {
this.logger.error("Japan Post ZIP search error", { this.logger.error("Japan Post ZIP search error", {
zipCode, zipCode,
endpoint: url, endpoint: url,

View File

@ -16,6 +16,7 @@ import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import PubSubApiClientPkg from "salesforce-pubsub-api-client"; import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "../../services/salesforce-connection.service.js"; import { SalesforceConnection } from "../../services/salesforce-connection.service.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type { PubSubClient, PubSubClientConstructor, PubSubCallback } from "./pubsub.types.js"; import type { PubSubClient, PubSubClientConstructor, PubSubCallback } from "./pubsub.types.js";
import { parseNumRequested } from "./pubsub.utils.js"; import { parseNumRequested } from "./pubsub.utils.js";
@ -63,7 +64,9 @@ export class PubSubClientService implements OnModuleDestroy {
const instanceUrl = this.sfConnection.getInstanceUrl(); const instanceUrl = this.sfConnection.getInstanceUrl();
if (!accessToken || !instanceUrl) { if (!accessToken || !instanceUrl) {
throw new Error("Salesforce access token or instance URL missing for Pub/Sub client"); throw new SalesforceOperationException(
"Salesforce access token or instance URL missing for Pub/Sub client"
);
} }
const pubSubEndpoint = const pubSubEndpoint =

View File

@ -10,6 +10,7 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "../salesforce-connection.service.js"; import { SalesforceConnection } from "../salesforce-connection.service.js";
import { assertSalesforceId } from "../../utils/soql.util.js"; import { assertSalesforceId } from "../../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js"; import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js";
import { import {
@ -19,6 +20,13 @@ import {
requireStringField, requireStringField,
} from "./opportunity.types.js"; } from "./opportunity.types.js";
/**
* Salesforce Opportunity record payload.
* Keys are dynamic (resolved from OPPORTUNITY_FIELD_MAP at runtime),
* so a static interface is not possible.
*/
type SalesforceOpportunityPayload = Record<string, unknown>;
@Injectable() @Injectable()
export class OpportunityCancellationService { export class OpportunityCancellationService {
constructor( constructor(
@ -43,7 +51,8 @@ export class OpportunityCancellationService {
const safeData = (() => { const safeData = (() => {
const unknownData: unknown = data; const unknownData: unknown = data;
if (!isRecord(unknownData)) throw new Error("Invalid cancellation data"); if (!isRecord(unknownData))
throw new SalesforceOperationException("Invalid cancellation data");
return { return {
scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"), scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"),
@ -58,7 +67,7 @@ export class OpportunityCancellationService {
cancellationNotice: safeData.cancellationNotice, cancellationNotice: safeData.cancellationNotice,
}); });
const payload: Record<string, unknown> = { const payload: SalesforceOpportunityPayload = {
Id: safeOppId, Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
[OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: safeData.scheduledCancellationDate, [OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: safeData.scheduledCancellationDate,
@ -69,7 +78,9 @@ export class OpportunityCancellationService {
try { try {
const updateMethod = this.sf.sobject("Opportunity").update; const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) { if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available"); throw new SalesforceOperationException(
"Salesforce Opportunity update method not available"
);
} }
await updateMethod(payload as Record<string, unknown> & { Id: string }); await updateMethod(payload as Record<string, unknown> & { Id: string });
@ -83,7 +94,7 @@ export class OpportunityCancellationService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
opportunityId: safeOppId, opportunityId: safeOppId,
}); });
throw new Error("Failed to update cancellation information"); throw new SalesforceOperationException("Failed to update cancellation information");
} }
} }
@ -103,7 +114,8 @@ export class OpportunityCancellationService {
const safeData = (() => { const safeData = (() => {
const unknownData: unknown = data; const unknownData: unknown = data;
if (!isRecord(unknownData)) throw new Error("Invalid SIM cancellation data"); if (!isRecord(unknownData))
throw new SalesforceOperationException("Invalid SIM cancellation data");
return { return {
scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"), scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"),
@ -117,7 +129,7 @@ export class OpportunityCancellationService {
cancellationNotice: safeData.cancellationNotice, cancellationNotice: safeData.cancellationNotice,
}); });
const payload: Record<string, unknown> = { const payload: SalesforceOpportunityPayload = {
Id: safeOppId, Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING, [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
[OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate]: safeData.scheduledCancellationDate, [OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate]: safeData.scheduledCancellationDate,
@ -127,7 +139,9 @@ export class OpportunityCancellationService {
try { try {
const updateMethod = this.sf.sobject("Opportunity").update; const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) { if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available"); throw new SalesforceOperationException(
"Salesforce Opportunity update method not available"
);
} }
await updateMethod(payload as Record<string, unknown> & { Id: string }); await updateMethod(payload as Record<string, unknown> & { Id: string });
@ -141,7 +155,7 @@ export class OpportunityCancellationService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
opportunityId: safeOppId, opportunityId: safeOppId,
}); });
throw new Error("Failed to update SIM cancellation information"); throw new SalesforceOperationException("Failed to update SIM cancellation information");
} }
} }
@ -155,7 +169,7 @@ export class OpportunityCancellationService {
opportunityId: safeOppId, opportunityId: safeOppId,
}); });
const payload: Record<string, unknown> = { const payload: SalesforceOpportunityPayload = {
Id: safeOppId, Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLED, [OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLED,
}; };
@ -163,7 +177,9 @@ export class OpportunityCancellationService {
try { try {
const updateMethod = this.sf.sobject("Opportunity").update; const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) { if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available"); throw new SalesforceOperationException(
"Salesforce Opportunity update method not available"
);
} }
await updateMethod(payload as Record<string, unknown> & { Id: string }); await updateMethod(payload as Record<string, unknown> & { Id: string });
@ -176,7 +192,7 @@ export class OpportunityCancellationService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
opportunityId: safeOppId, opportunityId: safeOppId,
}); });
throw new Error("Failed to mark cancellation complete"); throw new SalesforceOperationException("Failed to mark cancellation complete");
} }
} }
} }

View File

@ -11,6 +11,7 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "../salesforce-connection.service.js"; import { SalesforceConnection } from "../salesforce-connection.service.js";
import { assertSalesforceId } from "../../utils/soql.util.js"; import { assertSalesforceId } from "../../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import { import {
type OpportunityStageValue, type OpportunityStageValue,
type OpportunityProductTypeValue, type OpportunityProductTypeValue,
@ -22,6 +23,13 @@ import {
} from "@customer-portal/domain/opportunity"; } from "@customer-portal/domain/opportunity";
import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js"; import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js";
/**
* Salesforce Opportunity record payload.
* Keys are dynamic (resolved from OPPORTUNITY_FIELD_MAP at runtime),
* so a static interface is not possible.
*/
type SalesforceOpportunityPayload = Record<string, unknown>;
@Injectable() @Injectable()
export class OpportunityMutationService { export class OpportunityMutationService {
private readonly opportunityRecordTypeIds: Partial< private readonly opportunityRecordTypeIds: Partial<
@ -66,7 +74,7 @@ export class OpportunityMutationService {
const commodityType = getDefaultCommodityType(request.productType); const commodityType = getDefaultCommodityType(request.productType);
const recordTypeId = this.resolveOpportunityRecordTypeId(request.productType); const recordTypeId = this.resolveOpportunityRecordTypeId(request.productType);
const payload: Record<string, unknown> = { const payload: SalesforceOpportunityPayload = {
[OPPORTUNITY_FIELD_MAP.name]: opportunityName, [OPPORTUNITY_FIELD_MAP.name]: opportunityName,
[OPPORTUNITY_FIELD_MAP.accountId]: safeAccountId, [OPPORTUNITY_FIELD_MAP.accountId]: safeAccountId,
[OPPORTUNITY_FIELD_MAP.stage]: request.stage, [OPPORTUNITY_FIELD_MAP.stage]: request.stage,
@ -83,13 +91,15 @@ export class OpportunityMutationService {
try { try {
const createMethod = this.sf.sobject("Opportunity").create; const createMethod = this.sf.sobject("Opportunity").create;
if (!createMethod) { if (!createMethod) {
throw new Error("Salesforce Opportunity create method not available"); throw new SalesforceOperationException(
"Salesforce Opportunity create method not available"
);
} }
const result = (await createMethod(payload)) as { id?: string; success?: boolean }; const result = (await createMethod(payload)) as { id?: string; success?: boolean };
if (!result?.id) { if (!result?.id) {
throw new Error("Salesforce did not return Opportunity ID"); throw new SalesforceOperationException("Salesforce did not return Opportunity ID");
} }
this.logger.log("Opportunity created successfully", { this.logger.log("Opportunity created successfully", {
@ -116,7 +126,7 @@ export class OpportunityMutationService {
} }
this.logger.error(errorDetails, "Failed to create Opportunity"); this.logger.error(errorDetails, "Failed to create Opportunity");
throw new Error("Failed to create service lifecycle record"); throw new SalesforceOperationException("Failed to create service lifecycle record");
} }
} }
@ -136,7 +146,7 @@ export class OpportunityMutationService {
reason, reason,
}); });
const payload: Record<string, unknown> = { const payload: SalesforceOpportunityPayload = {
Id: safeOppId, Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: stage, [OPPORTUNITY_FIELD_MAP.stage]: stage,
}; };
@ -144,7 +154,9 @@ export class OpportunityMutationService {
try { try {
const updateMethod = this.sf.sobject("Opportunity").update; const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) { if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available"); throw new SalesforceOperationException(
"Salesforce Opportunity update method not available"
);
} }
await updateMethod(payload as Record<string, unknown> & { Id: string }); await updateMethod(payload as Record<string, unknown> & { Id: string });
@ -159,7 +171,7 @@ export class OpportunityMutationService {
opportunityId: safeOppId, opportunityId: safeOppId,
stage, stage,
}); });
throw new Error("Failed to update service lifecycle stage"); throw new SalesforceOperationException("Failed to update service lifecycle stage");
} }
} }
@ -177,7 +189,7 @@ export class OpportunityMutationService {
whmcsServiceId, whmcsServiceId,
}); });
const payload: Record<string, unknown> = { const payload: SalesforceOpportunityPayload = {
Id: safeOppId, Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId, [OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId,
[OPPORTUNITY_FIELD_MAP.whmcsRegistrationUrl]: `productselect=${whmcsServiceId}`, [OPPORTUNITY_FIELD_MAP.whmcsRegistrationUrl]: `productselect=${whmcsServiceId}`,
@ -186,7 +198,9 @@ export class OpportunityMutationService {
try { try {
const updateMethod = this.sf.sobject("Opportunity").update; const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) { if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available"); throw new SalesforceOperationException(
"Salesforce Opportunity update method not available"
);
} }
await updateMethod(payload as Record<string, unknown> & { Id: string }); await updateMethod(payload as Record<string, unknown> & { Id: string });
@ -220,7 +234,7 @@ export class OpportunityMutationService {
try { try {
const updateMethod = this.sf.sobject("Order").update; const updateMethod = this.sf.sobject("Order").update;
if (!updateMethod) { if (!updateMethod) {
throw new Error("Salesforce Order update method not available"); throw new SalesforceOperationException("Salesforce Order update method not available");
} }
await updateMethod({ await updateMethod({

View File

@ -2,11 +2,20 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import { SalesforceConnection } from "./salesforce-connection.service.js"; import { SalesforceConnection } from "./salesforce-connection.service.js";
import type { SalesforceAccountRecord } from "@customer-portal/domain/customer"; import type { SalesforceAccountRecord } from "@customer-portal/domain/customer";
import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
import { customerNumberSchema, salesforceIdSchema } from "@customer-portal/domain/common"; import { customerNumberSchema, salesforceIdSchema } from "@customer-portal/domain/common";
/**
* Salesforce Account/Contact record payloads.
* Mix of static Salesforce fields and dynamic keys from ConfigService
* (portal status, portal source, etc.), so a static interface is not possible.
*/
type SalesforceAccountPayload = Record<string, unknown>;
type SalesforceContactPayload = Record<string, unknown>;
/** /**
* Salesforce Account Service * Salesforce Account Service
* *
@ -59,7 +68,7 @@ export class SalesforceAccountService {
this.logger.error("Failed to find account by customer number", { this.logger.error("Failed to find account by customer number", {
error: extractErrorMessage(error), error: extractErrorMessage(error),
}); });
throw new Error("Failed to find account"); throw new SalesforceOperationException("Failed to find account");
} }
} }
@ -90,7 +99,7 @@ export class SalesforceAccountService {
this.logger.error("Failed to find account with details by customer number", { this.logger.error("Failed to find account with details by customer number", {
error: extractErrorMessage(error), error: extractErrorMessage(error),
}); });
throw new Error("Failed to find account"); throw new SalesforceOperationException("Failed to find account");
} }
} }
@ -126,7 +135,7 @@ export class SalesforceAccountService {
accountId, accountId,
error: extractErrorMessage(error), error: extractErrorMessage(error),
}); });
throw new Error("Failed to get account details"); throw new SalesforceOperationException("Failed to get account details");
} }
} }
@ -183,7 +192,7 @@ export class SalesforceAccountService {
const personAccountRecordTypeId = await this.resolvePersonAccountRecordTypeId(); const personAccountRecordTypeId = await this.resolvePersonAccountRecordTypeId();
const accountPayload: Record<string, unknown> = { const accountPayload: SalesforceAccountPayload = {
// Person Account fields (required for Person Accounts) // Person Account fields (required for Person Accounts)
FirstName: data.firstName, FirstName: data.firstName,
LastName: data.lastName, LastName: data.lastName,
@ -191,7 +200,7 @@ export class SalesforceAccountService {
PersonMobilePhone: data.phone, PersonMobilePhone: data.phone,
// Record type for Person Accounts (required) // Record type for Person Accounts (required)
RecordTypeId: personAccountRecordTypeId, RecordTypeId: personAccountRecordTypeId,
// Portal tracking fields // Portal tracking fields (dynamic keys from ConfigService)
[this.portalStatusField]: data.portalStatus ?? "Not Yet", [this.portalStatusField]: data.portalStatus ?? "Not Yet",
[this.portalSourceField]: data.portalSource, [this.portalSourceField]: data.portalSource,
}; };
@ -206,13 +215,15 @@ export class SalesforceAccountService {
try { try {
const createMethod = this.connection.sobject("Account").create; const createMethod = this.connection.sobject("Account").create;
if (!createMethod) { if (!createMethod) {
throw new Error("Salesforce create method not available"); throw new SalesforceOperationException("Salesforce create method not available");
} }
const result = await createMethod(accountPayload); const result = await createMethod(accountPayload);
if (!result || typeof result !== "object" || !("id" in result)) { if (!result || typeof result !== "object" || !("id" in result)) {
throw new Error("Salesforce Account creation failed - no ID returned"); throw new SalesforceOperationException(
"Salesforce Account creation failed - no ID returned"
);
} }
const accountId = result.id as string; const accountId = result.id as string;
@ -275,7 +286,7 @@ export class SalesforceAccountService {
}, },
"Failed to create Salesforce Account" "Failed to create Salesforce Account"
); );
throw new Error("Failed to create customer account in CRM"); throw new SalesforceOperationException("Failed to create customer account in CRM");
} }
} }
@ -295,14 +306,14 @@ export class SalesforceAccountService {
)) as SalesforceResponse<{ Id: string; Name: string }>; )) as SalesforceResponse<{ Id: string; Name: string }>;
if (recordTypeQuery.totalSize === 0) { if (recordTypeQuery.totalSize === 0) {
throw new Error( throw new SalesforceOperationException(
"No Person Account record type found. Person Accounts may not be enabled in this Salesforce org." "No Person Account record type found. Person Accounts may not be enabled in this Salesforce org."
); );
} }
const record = recordTypeQuery.records[0]; const record = recordTypeQuery.records[0];
if (!record) { if (!record) {
throw new Error("Person Account RecordType record not found"); throw new SalesforceOperationException("Person Account RecordType record not found");
} }
const recordTypeId = record.Id; const recordTypeId = record.Id;
this.logger.debug("Found Person Account RecordType", { this.logger.debug("Found Person Account RecordType", {
@ -314,7 +325,7 @@ export class SalesforceAccountService {
this.logger.error("Failed to query Person Account RecordType", { this.logger.error("Failed to query Person Account RecordType", {
error: extractErrorMessage(error), error: extractErrorMessage(error),
}); });
throw new Error("Failed to determine Person Account record type"); throw new SalesforceOperationException("Failed to determine Person Account record type");
} }
} }
@ -349,11 +360,11 @@ export class SalesforceAccountService {
const personContactId = accountRecord.records[0]?.PersonContactId; const personContactId = accountRecord.records[0]?.PersonContactId;
if (!personContactId) { if (!personContactId) {
throw new Error("PersonContactId not found for Person Account"); throw new SalesforceOperationException("PersonContactId not found for Person Account");
} }
// Update the PersonContact with additional fields // Update the PersonContact with additional fields
const contactPayload: Record<string, unknown> = { const contactPayload: SalesforceContactPayload = {
Id: personContactId, Id: personContactId,
MobilePhone: data.phone, MobilePhone: data.phone,
Sex__c: mapGenderToSalesforce(data.gender), Sex__c: mapGenderToSalesforce(data.gender),
@ -362,7 +373,7 @@ export class SalesforceAccountService {
const updateMethod = this.connection.sobject("Contact").update; const updateMethod = this.connection.sobject("Contact").update;
if (!updateMethod) { if (!updateMethod) {
throw new Error("Salesforce update method not available"); throw new SalesforceOperationException("Salesforce update method not available");
} }
await updateMethod(contactPayload as Record<string, unknown> & { Id: string }); await updateMethod(contactPayload as Record<string, unknown> & { Id: string });
@ -374,7 +385,7 @@ export class SalesforceAccountService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
accountId: data.accountId, accountId: data.accountId,
}); });
throw new Error("Failed to update customer contact in CRM"); throw new SalesforceOperationException("Failed to update customer contact in CRM");
} }
} }
@ -417,7 +428,7 @@ export class SalesforceAccountService {
} }
// Build contact update payload with Japanese mailing address fields // Build contact update payload with Japanese mailing address fields
const contactPayload: Record<string, unknown> = { const contactPayload: SalesforceContactPayload = {
Id: personContactId, Id: personContactId,
MailingStreet: address.mailingStreet || "", MailingStreet: address.mailingStreet || "",
MailingCity: address.mailingCity, MailingCity: address.mailingCity,
@ -466,7 +477,7 @@ export class SalesforceAccountService {
update: SalesforceAccountPortalUpdate update: SalesforceAccountPortalUpdate
): Promise<void> { ): Promise<void> {
const validAccountId = salesforceIdSchema.parse(accountId); const validAccountId = salesforceIdSchema.parse(accountId);
const payload: Record<string, unknown> = { Id: validAccountId }; const payload: SalesforceAccountPayload = { Id: validAccountId };
if (update.status) { if (update.status) {
payload[this.portalStatusField] = update.status; payload[this.portalStatusField] = update.status;

View File

@ -16,6 +16,7 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "./salesforce-connection.service.js"; import { SalesforceConnection } from "./salesforce-connection.service.js";
import { assertSalesforceId } from "../utils/soql.util.js"; import { assertSalesforceId } from "../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.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";
@ -44,6 +45,13 @@ import {
// Types // Types
// ============================================================================ // ============================================================================
/**
* Salesforce Case record payload.
* Keys are dynamic (resolved from CASE_FIELDS constant at runtime),
* so a static interface is not possible.
*/
type SalesforceCasePayload = Record<string, unknown>;
/** /**
* Parameters for creating any case in Salesforce. * Parameters for creating any case in Salesforce.
* *
@ -117,7 +125,7 @@ export class SalesforceCaseService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
accountId: safeAccountId, accountId: safeAccountId,
}); });
throw new Error("Failed to fetch support cases"); throw new SalesforceOperationException("Failed to fetch support cases");
} }
} }
@ -154,7 +162,7 @@ export class SalesforceCaseService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
caseId: safeCaseId, caseId: safeCaseId,
}); });
throw new Error("Failed to fetch support case"); throw new SalesforceOperationException("Failed to fetch support case");
} }
} }
@ -191,7 +199,7 @@ export class SalesforceCaseService {
? toSalesforcePriority(params.priority) ? toSalesforcePriority(params.priority)
: SALESFORCE_CASE_PRIORITY.MEDIUM; : SALESFORCE_CASE_PRIORITY.MEDIUM;
const casePayload: Record<string, unknown> = { const casePayload: SalesforceCasePayload = {
[CASE_FIELDS.origin]: params.origin, [CASE_FIELDS.origin]: params.origin,
[CASE_FIELDS.status]: SALESFORCE_CASE_STATUS.NEW, [CASE_FIELDS.status]: SALESFORCE_CASE_STATUS.NEW,
[CASE_FIELDS.priority]: sfPriority, [CASE_FIELDS.priority]: sfPriority,
@ -221,7 +229,7 @@ export class SalesforceCaseService {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string }; const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
if (!created.id) { if (!created.id) {
throw new Error("Salesforce did not return a case ID"); throw new SalesforceOperationException("Salesforce did not return a case ID");
} }
// Fetch the created case to get the CaseNumber // Fetch the created case to get the CaseNumber
@ -241,7 +249,7 @@ export class SalesforceCaseService {
accountIdTail: safeAccountId.slice(-4), accountIdTail: safeAccountId.slice(-4),
origin: params.origin, origin: params.origin,
}); });
throw new Error("Failed to create case"); throw new SalesforceOperationException("Failed to create case");
} }
} }
@ -254,7 +262,7 @@ export class SalesforceCaseService {
async createWebCase(params: CreateWebCaseParams): Promise<{ id: string; caseNumber: string }> { async createWebCase(params: CreateWebCaseParams): Promise<{ id: string; caseNumber: string }> {
this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail }); this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail });
const casePayload: Record<string, unknown> = { const casePayload: SalesforceCasePayload = {
[CASE_FIELDS.origin]: params.origin ?? "Web", [CASE_FIELDS.origin]: params.origin ?? "Web",
[CASE_FIELDS.status]: SALESFORCE_CASE_STATUS.NEW, [CASE_FIELDS.status]: SALESFORCE_CASE_STATUS.NEW,
[CASE_FIELDS.priority]: params.priority ?? SALESFORCE_CASE_PRIORITY.MEDIUM, [CASE_FIELDS.priority]: params.priority ?? SALESFORCE_CASE_PRIORITY.MEDIUM,
@ -269,7 +277,7 @@ export class SalesforceCaseService {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string }; const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
if (!created.id) { if (!created.id) {
throw new Error("Salesforce did not return a case ID"); throw new SalesforceOperationException("Salesforce did not return a case ID");
} }
// Fetch the created case to get the CaseNumber // Fetch the created case to get the CaseNumber
@ -288,7 +296,7 @@ export class SalesforceCaseService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
email: params.suppliedEmail, email: params.suppliedEmail,
}); });
throw new Error("Failed to create contact request"); throw new SalesforceOperationException("Failed to create contact request");
} }
} }
@ -384,7 +392,7 @@ export class SalesforceCaseService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
caseId: safeCaseId, caseId: safeCaseId,
}); });
throw new Error("Failed to fetch case messages"); throw new SalesforceOperationException("Failed to fetch case messages");
} }
} }
@ -414,7 +422,7 @@ export class SalesforceCaseService {
{ caseId: safeCaseId }, { caseId: safeCaseId },
"Attempted to add comment to non-existent/unauthorized case" "Attempted to add comment to non-existent/unauthorized case"
); );
throw new Error("Case not found"); throw new SalesforceOperationException("Case not found");
} }
this.logger.log("Adding comment to case", { this.logger.log("Adding comment to case", {
@ -434,7 +442,7 @@ export class SalesforceCaseService {
}; };
if (!created.id) { if (!created.id) {
throw new Error("Salesforce did not return a comment ID"); throw new SalesforceOperationException("Salesforce did not return a comment ID");
} }
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
@ -450,7 +458,7 @@ export class SalesforceCaseService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
caseId: safeCaseId, caseId: safeCaseId,
}); });
throw new Error("Failed to add comment"); throw new SalesforceOperationException("Failed to add comment");
} }
} }
} }

View File

@ -16,6 +16,7 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "./salesforce-connection.service.js"; import { SalesforceConnection } from "./salesforce-connection.service.js";
import { assertSalesforceId, buildInClause } from "../utils/soql.util.js"; import { assertSalesforceId, buildInClause } from "../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type { OrderDetails, OrderSummary } from "@customer-portal/domain/orders"; import type { OrderDetails, OrderSummary } from "@customer-portal/domain/orders";
import type { import type {
SalesforceOrderItemRecord, SalesforceOrderItemRecord,
@ -28,6 +29,13 @@ import {
import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
import { SalesforceOrderFieldMapService } from "../config/order-field-map.service.js"; import { SalesforceOrderFieldMapService } from "../config/order-field-map.service.js";
/**
* Salesforce Order record payload.
* Keys are dynamic (resolved from SalesforceOrderFieldMap at runtime),
* so a static interface is not possible.
*/
type SalesforceOrderPayload = Record<string, unknown>;
/** /**
* Salesforce Order Service * Salesforce Order Service
* *
@ -114,7 +122,7 @@ export class SalesforceOrderService {
/** /**
* Create a new order in Salesforce * Create a new order in Salesforce
*/ */
async createOrder(orderFields: Record<string, unknown>): Promise<{ id: string }> { async createOrder(orderFields: SalesforceOrderPayload): Promise<{ id: string }> {
const typeField = this.orderFieldMap.fields.order.type; const typeField = this.orderFieldMap.fields.order.type;
this.logger.log({ orderType: orderFields[typeField] }, "Creating Salesforce Order"); this.logger.log({ orderType: orderFields[typeField] }, "Creating Salesforce Order");
@ -132,7 +140,7 @@ export class SalesforceOrderService {
} }
async createOrderWithItems( async createOrderWithItems(
orderFields: Record<string, unknown>, orderFields: SalesforceOrderPayload,
items: Array<{ pricebookEntryId: string; unitPrice: number; quantity: number; sku?: string }> items: Array<{ pricebookEntryId: string; unitPrice: number; quantity: number; sku?: string }>
): Promise<{ id: string }> { ): Promise<{ id: string }> {
if (!items.length) { if (!items.length) {
@ -172,7 +180,7 @@ export class SalesforceOrderService {
.map(err => `[${err.statusCode}] ${err.message}`) .map(err => `[${err.statusCode}] ${err.message}`)
.join("; "); .join("; ");
throw new Error( throw new SalesforceOperationException(
errorDetails || "Salesforce composite tree returned errors during order creation" errorDetails || "Salesforce composite tree returned errors during order creation"
); );
} }
@ -182,7 +190,9 @@ export class SalesforceOrderService {
); );
if (!orderResult?.id) { if (!orderResult?.id) {
throw new Error("Salesforce composite tree response missing order ID"); throw new SalesforceOperationException(
"Salesforce composite tree response missing order ID"
);
} }
this.logger.log( this.logger.log(

View File

@ -65,6 +65,15 @@ interface SalesforceSIMInventoryResponse {
}>; }>;
} }
/** Payload for updating a SIM_Inventory__c record */
interface SimInventoryUpdatePayload {
Id: string;
Status__c: SimInventoryStatus;
Assigned_Account__c?: string;
Assigned_Order__c?: string;
SIM_Type__c?: string;
}
@Injectable() @Injectable()
export class SalesforceSIMInventoryService { export class SalesforceSIMInventoryService {
constructor( constructor(
@ -193,20 +202,20 @@ export class SalesforceSIMInventoryService {
}); });
try { try {
const updatePayload: Record<string, unknown> = { const updatePayload: SimInventoryUpdatePayload = {
Id: safeId, Id: safeId,
Status__c: SIM_INVENTORY_STATUS.ASSIGNED, Status__c: SIM_INVENTORY_STATUS.ASSIGNED,
}; };
// Add optional assignment fields if provided // Add optional assignment fields if provided
if (details?.accountId) { if (details?.accountId) {
updatePayload["Assigned_Account__c"] = details.accountId; updatePayload.Assigned_Account__c = details.accountId;
} }
if (details?.orderId) { if (details?.orderId) {
updatePayload["Assigned_Order__c"] = details.orderId; updatePayload.Assigned_Order__c = details.orderId;
} }
if (details?.simType) { if (details?.simType) {
updatePayload["SIM_Type__c"] = details.simType; updatePayload.SIM_Type__c = details.simType;
} }
await this.sf.sobject("SIM_Inventory__c").update?.(updatePayload as { Id: string }); await this.sf.sobject("SIM_Inventory__c").update?.(updatePayload as { Id: string });

View File

@ -16,15 +16,11 @@ export class WhmcsErrorHandlerService {
/** /**
* Handle WHMCS API error response * Handle WHMCS API error response
*/ */
handleApiError( handleApiError(errorResponse: WhmcsErrorResponse, action: string): never {
errorResponse: WhmcsErrorResponse,
action: string,
params: Record<string, unknown>
): never {
const message = errorResponse.message; const message = errorResponse.message;
const errorCode = errorResponse.errorcode; const errorCode = errorResponse.errorcode;
const mapped = this.mapProviderErrorToDomain(action, message, errorCode, params); const mapped = this.mapProviderErrorToDomain(action, message, errorCode);
throw new DomainHttpException(mapped.code, mapped.status); throw new DomainHttpException(mapped.code, mapped.status);
} }
@ -172,8 +168,7 @@ export class WhmcsErrorHandlerService {
private mapProviderErrorToDomain( private mapProviderErrorToDomain(
action: string, action: string,
message: string, message: string,
providerErrorCode: string | undefined, providerErrorCode: string | undefined
params: Record<string, unknown>
): { code: ErrorCodeType; status: HttpStatus } { ): { code: ErrorCodeType; status: HttpStatus } {
// 1) ValidateLogin: user credentials are wrong (expected) // 1) ValidateLogin: user credentials are wrong (expected)
if ( if (
@ -199,7 +194,6 @@ export class WhmcsErrorHandlerService {
} }
// 5) Default: external service error // 5) Default: external service error
void params; // reserved for future mapping detail; keep signature stable
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY }; return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY };
} }

View File

@ -1,6 +1,7 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import { redactForLogs } from "@bff/core/logging/redaction.util.js"; import { redactForLogs } from "@bff/core/logging/redaction.util.js";
import type { WhmcsResponse } from "@customer-portal/domain/common/providers"; import type { WhmcsResponse } from "@customer-portal/domain/common/providers";
import type { import type {
@ -140,7 +141,7 @@ export class WhmcsHttpClientService {
} }
} }
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new WhmcsOperationException(`HTTP ${response.status}: ${response.statusText}`);
} }
return this.parseResponse<T>(responseText, action, params); return this.parseResponse<T>(responseText, action, params);
@ -242,7 +243,7 @@ export class WhmcsHttpClientService {
parseError: extractErrorMessage(parseError), parseError: extractErrorMessage(parseError),
params: redactForLogs(params), params: redactForLogs(params),
}); });
throw new Error("Invalid JSON response from WHMCS API"); throw new WhmcsOperationException("Invalid JSON response from WHMCS API");
} }
// Validate basic response structure // Validate basic response structure
@ -255,7 +256,7 @@ export class WhmcsHttpClientService {
: { responseText: responseText.slice(0, 500) }), : { responseText: responseText.slice(0, 500) }),
params: redactForLogs(params), params: redactForLogs(params),
}); });
throw new Error("Invalid response structure from WHMCS API"); throw new WhmcsOperationException("Invalid response structure from WHMCS API");
} }
// Handle error responses according to WHMCS API documentation // Handle error responses according to WHMCS API documentation

View File

@ -37,12 +37,48 @@ import type {
WhmcsProductListResponse, WhmcsProductListResponse,
} from "@customer-portal/domain/subscriptions/providers"; } from "@customer-portal/domain/subscriptions/providers";
import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/services/providers"; import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/services/providers";
import type { WhmcsAddOrderPayload } from "@customer-portal/domain/orders/providers";
import type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers"; import type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers";
import type { import type {
WhmcsRequestOptions, WhmcsRequestOptions,
WhmcsConnectionStats, WhmcsConnectionStats,
} from "../connection/types/connection.types.js"; } from "../connection/types/connection.types.js";
/**
* Parameters for WHMCS UpdateClient API.
* Any client field can be updated; these are the most common ones.
*/
interface WhmcsUpdateClientParams {
firstname?: string | undefined;
lastname?: string | undefined;
companyname?: string | undefined;
email?: string | undefined;
address1?: string | undefined;
address2?: string | undefined;
city?: string | undefined;
state?: string | undefined;
postcode?: string | undefined;
country?: string | undefined;
phonenumber?: string | undefined;
currency?: string | number | undefined;
language?: string | undefined;
status?: string | undefined;
notes?: string | undefined;
[key: string]: unknown;
}
/**
* Parameters for WHMCS GetOrders API.
*/
interface WhmcsGetOrdersParams {
id?: string;
userid?: number;
status?: string;
limitstart?: number;
limitnum?: number;
[key: string]: unknown;
}
/** /**
* WHMCS Connection Facade * WHMCS Connection Facade
* *
@ -105,7 +141,7 @@ export class WhmcsConnectionFacade implements OnModuleInit {
if (response.result === "error") { if (response.result === "error") {
const errorResponse = response as WhmcsErrorResponse; const errorResponse = response as WhmcsErrorResponse;
this.errorHandler.handleApiError(errorResponse, action, params); this.errorHandler.handleApiError(errorResponse, action);
} }
return response.data as T; return response.data as T;
@ -189,7 +225,7 @@ export class WhmcsConnectionFacade implements OnModuleInit {
async updateClient( async updateClient(
clientId: number, clientId: number,
updateData: Record<string, unknown> updateData: WhmcsUpdateClientParams
): Promise<{ result: string }> { ): Promise<{ result: string }> {
return this.makeRequest<{ result: string }>("UpdateClient", { return this.makeRequest<{ result: string }>("UpdateClient", {
clientid: clientId, clientid: clientId,
@ -244,11 +280,11 @@ export class WhmcsConnectionFacade implements OnModuleInit {
// ORDER OPERATIONS (Used by order services) // ORDER OPERATIONS (Used by order services)
// ========================================== // ==========================================
async addOrder(params: Record<string, unknown>) { async addOrder(params: WhmcsAddOrderPayload) {
return this.makeRequest("AddOrder", params); return this.makeRequest("AddOrder", params);
} }
async getOrders(params: Record<string, unknown> = {}) { async getOrders(params: WhmcsGetOrdersParams = {}) {
return this.makeRequest("GetOrders", params); return this.makeRequest("GetOrders", params);
} }
@ -323,10 +359,6 @@ export class WhmcsConnectionFacade implements OnModuleInit {
return this.configService.getBaseUrl(); return this.configService.getBaseUrl();
} }
// ==========================================
// UTILITY METHODS
// ==========================================
/** /**
* Determine request priority based on action type * Determine request priority based on action type
*/ */

View File

@ -114,7 +114,7 @@ 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.result !== "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);
@ -135,13 +135,12 @@ 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"], 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 || "Unknown error"}`,
{ operation: "getCurrencies" } { operation: "getCurrencies" }
); );
} }
@ -187,7 +186,9 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
} }
} else { } else {
// Fallback: try to parse flat format (currencies[currency][0][id], etc.) // Fallback: try to parse flat format (currencies[currency][0][id], etc.)
const currencyKeys = Object.keys(response).filter( // The flat format has dynamic keys not present in the typed schema — values are always strings
const flat = response as unknown as Record<string, string>;
const currencyKeys = Object.keys(flat).filter(
key => key.startsWith("currencies[currency][") && key.includes("][id]") key => key.startsWith("currencies[currency][") && key.includes("][id]")
); );
@ -203,12 +204,12 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
// 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: Number.parseInt(String(response[`currencies[currency][${index}][id]`])) || 0, id: Number.parseInt(String(flat[`currencies[currency][${index}][id]`])) || 0,
code: String(response[`currencies[currency][${index}][code]`] || ""), code: String(flat[`currencies[currency][${index}][code]`] ?? ""),
prefix: String(response[`currencies[currency][${index}][prefix]`] || ""), prefix: String(flat[`currencies[currency][${index}][prefix]`] ?? ""),
suffix: String(response[`currencies[currency][${index}][suffix]`] || ""), suffix: String(flat[`currencies[currency][${index}][suffix]`] ?? ""),
format: String(response[`currencies[currency][${index}][format]`] || "1"), format: String(flat[`currencies[currency][${index}][format]`] ?? "1"),
rate: String(response[`currencies[currency][${index}][rate]`] || "1.00000"), rate: String(flat[`currencies[currency][${index}][rate]`] ?? "1.00000"),
}; };
// Validate that we have essential currency data // Validate that we have essential currency data

View File

@ -4,20 +4,39 @@ import { WhmcsConnectionFacade } from "../facades/whmcs.facade.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type {
WhmcsOrderItem,
WhmcsAddOrderParams,
WhmcsAddOrderResponse,
WhmcsOrderResult,
} from "@customer-portal/domain/orders/providers";
import { import {
buildWhmcsAddOrderPayload, buildWhmcsAddOrderPayload,
whmcsAddOrderResponseSchema, whmcsAddOrderResponseSchema,
whmcsAcceptOrderResponseSchema, whmcsAcceptOrderResponseSchema,
type WhmcsOrderItem,
type WhmcsAddOrderParams,
type WhmcsAddOrderResponse,
type WhmcsOrderResult,
type WhmcsAddOrderPayload,
type WhmcsAcceptOrderResponse,
} from "@customer-portal/domain/orders/providers"; } from "@customer-portal/domain/orders/providers";
export type { WhmcsOrderItem, WhmcsAddOrderParams, WhmcsOrderResult }; export type { WhmcsOrderItem, WhmcsAddOrderParams, WhmcsOrderResult };
/** Raw WHMCS order record from GetOrders API */
interface WhmcsGetOrdersOrder {
id?: string | number;
ordernum?: string;
date?: string;
status?: string;
invoiceid?: string | number;
[key: string]: unknown;
}
/** Raw WHMCS GetOrders API response */
interface WhmcsGetOrdersResponse {
orders?: {
order?: WhmcsGetOrdersOrder[];
};
totalresults?: number;
[key: string]: unknown;
}
@Injectable() @Injectable()
export class WhmcsOrderService { export class WhmcsOrderService {
constructor( constructor(
@ -47,16 +66,14 @@ 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"]) productCount: addOrderPayload.pid.length,
? (addOrderPayload["pid"] as unknown[]).length pids: addOrderPayload.pid,
: 0, quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added
pids: addOrderPayload["pid"], billingCycles: addOrderPayload.billingcycle,
quantities: addOrderPayload["qty"], // CRITICAL: Must be included for products to be added hasConfigOptions: Boolean(addOrderPayload.configoptions),
billingCycles: addOrderPayload["billingcycle"], hasCustomFields: Boolean(addOrderPayload.customfields),
hasConfigOptions: Boolean(addOrderPayload["configoptions"]), promoCode: addOrderPayload.promocode,
hasCustomFields: Boolean(addOrderPayload["customfields"]), paymentMethod: addOrderPayload.paymentmethod,
promoCode: addOrderPayload["promocode"],
paymentMethod: addOrderPayload["paymentmethod"],
}); });
// Call WHMCS AddOrder API // Call WHMCS AddOrder API
@ -135,7 +152,7 @@ export class WhmcsOrderService {
// Call WHMCS AcceptOrder API // Call WHMCS AcceptOrder API
// Note: The HTTP client throws errors automatically if result === "error" // Note: The HTTP client throws errors automatically if result === "error"
// So we only get here if the request was successful // So we only get here if the request was successful
const response = (await this.connection.acceptOrder(orderId)) as Record<string, unknown>; const response = (await this.connection.acceptOrder(orderId)) as WhmcsAcceptOrderResponse;
// Log the full response for debugging // Log the full response for debugging
this.logger.debug("WHMCS AcceptOrder response", { this.logger.debug("WHMCS AcceptOrder response", {
@ -248,15 +265,14 @@ export class WhmcsOrderService {
/** /**
* Get order details from WHMCS * Get order details from WHMCS
*/ */
async getOrderDetails(orderId: number): Promise<Record<string, unknown> | null> { async getOrderDetails(orderId: number): Promise<WhmcsGetOrdersOrder | null> {
try { try {
// Note: The HTTP client throws errors automatically if result === "error" // Note: The HTTP client throws errors automatically if result === "error"
const response = (await this.connection.getOrders({ const response = (await this.connection.getOrders({
id: orderId.toString(), id: orderId.toString(),
})) as Record<string, unknown>; })) as WhmcsGetOrdersResponse;
const orders = response["orders"] as { order?: Record<string, unknown>[] } | undefined; return response.orders?.order?.[0] ?? null;
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),
@ -302,19 +318,19 @@ export class WhmcsOrderService {
* *
* Delegates to shared mapper function from integration package * Delegates to shared mapper function from integration package
*/ */
private buildAddOrderPayload(params: WhmcsAddOrderParams): Record<string, unknown> { private buildAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAddOrderPayload {
const payload = buildWhmcsAddOrderPayload(params); const payload = buildWhmcsAddOrderPayload(params);
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;
} }
private toWhmcsOrderResult(response: WhmcsAddOrderResponse): WhmcsOrderResult { private toWhmcsOrderResult(response: WhmcsAddOrderResponse): WhmcsOrderResult {

View File

@ -15,6 +15,8 @@ import { TokenRevocationService } from "./infra/token/token-revocation.service.j
import { PasswordResetTokenService } from "./infra/token/password-reset-token.service.js"; import { PasswordResetTokenService } from "./infra/token/password-reset-token.service.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { AuthTokenService } from "./infra/token/token.service.js"; import { AuthTokenService } from "./infra/token/token.service.js";
import { TokenGeneratorService } from "./infra/token/token-generator.service.js";
import { TokenRefreshService } from "./infra/token/token-refresh.service.js";
import { JoseJwtService } from "./infra/token/jose-jwt.service.js"; import { JoseJwtService } from "./infra/token/jose-jwt.service.js";
import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service.js"; import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service.js";
import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js"; import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js";
@ -62,6 +64,8 @@ import { TrustedDeviceService } from "./infra/trusted-device/trusted-device.serv
TokenBlacklistService, TokenBlacklistService,
TokenStorageService, TokenStorageService,
TokenRevocationService, TokenRevocationService,
TokenGeneratorService,
TokenRefreshService,
AuthTokenService, AuthTokenService,
JoseJwtService, JoseJwtService,
PasswordResetTokenService, PasswordResetTokenService,

View File

@ -5,6 +5,8 @@
*/ */
export { AuthTokenService } from "./token.service.js"; export { AuthTokenService } from "./token.service.js";
export { TokenGeneratorService } from "./token-generator.service.js";
export { TokenRefreshService } from "./token-refresh.service.js";
export { JoseJwtService } from "./jose-jwt.service.js"; export { JoseJwtService } from "./jose-jwt.service.js";
export { TokenBlacklistService } from "./token-blacklist.service.js"; export { TokenBlacklistService } from "./token-blacklist.service.js";
export { TokenStorageService } from "./token-storage.service.js"; export { TokenStorageService } from "./token-storage.service.js";

View File

@ -0,0 +1,214 @@
import {
BadRequestException,
Injectable,
Inject,
ServiceUnavailableException,
} from "@nestjs/common";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import { randomBytes, createHash } from "crypto";
import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserRole } from "@customer-portal/domain/customer";
import { JoseJwtService } from "./jose-jwt.service.js";
import { TokenStorageService } from "./token-storage.service.js";
interface RefreshTokenPayload extends JWTPayload {
userId: string;
familyId?: string | undefined;
tokenId: string;
deviceId?: string | undefined;
userAgent?: string | undefined;
type: "refresh";
}
interface DeviceInfo {
deviceId?: string | undefined;
userAgent?: string | undefined;
}
const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
/**
* Token Generator Service
*
* Handles all token creation logic: access tokens, refresh tokens, and token pairs.
*/
@Injectable()
export class TokenGeneratorService {
readonly ACCESS_TOKEN_EXPIRY = "15m";
readonly REFRESH_TOKEN_EXPIRY = "7d";
constructor(
private readonly jwtService: JoseJwtService,
private readonly storage: TokenStorageService,
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Generate a new token pair with refresh token storage
*/
async generateTokenPair(
user: {
id: string;
email: string;
role?: UserRole;
},
deviceInfo?: DeviceInfo
): Promise<AuthTokens> {
if (!user.id || typeof user.id !== "string" || user.id.trim().length === 0) {
this.logger.error("Invalid user ID provided for token generation", {
userId: user.id,
});
throw new BadRequestException("Invalid user ID for token generation");
}
if (!user.email || typeof user.email !== "string" || user.email.trim().length === 0) {
this.logger.error("Invalid user email provided for token generation", {
userId: user.id,
});
throw new BadRequestException("Invalid user email for token generation");
}
const accessTokenId = this.generateTokenId();
const refreshFamilyId = this.generateTokenId();
const refreshTokenId = this.generateTokenId();
const accessPayload = {
sub: user.id,
email: user.email,
role: user.role || "USER",
tokenId: accessTokenId,
type: "access",
};
const refreshPayload: RefreshTokenPayload = {
userId: user.id,
familyId: refreshFamilyId,
tokenId: refreshTokenId,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
type: "refresh",
};
const accessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
const refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds);
const refreshTokenHash = this.hashToken(refreshToken);
const refreshAbsoluteExpiresAt = new Date(
Date.now() + refreshExpirySeconds * 1000
).toISOString();
if (this.redis.status !== "ready") {
this.logger.error("Redis not ready for token issuance", {
status: this.redis.status,
});
throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE);
}
try {
await this.storage.storeRefreshToken({
userId: user.id,
familyId: refreshFamilyId,
refreshTokenHash,
deviceInfo,
refreshExpirySeconds,
absoluteExpiresAt: refreshAbsoluteExpiresAt,
});
} catch (error) {
this.logger.error("Failed to store refresh token in Redis", {
error: error instanceof Error ? error.message : String(error),
userId: user.id,
});
throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE);
}
const accessExpiresAt = new Date(
Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)
).toISOString();
const refreshExpiresAt = refreshAbsoluteExpiresAt;
this.logger.debug("Generated new token pair", {
userId: user.id,
accessTokenId,
refreshFamilyId,
refreshTokenId,
});
return {
accessToken,
refreshToken,
expiresAt: accessExpiresAt,
refreshExpiresAt,
tokenType: "Bearer",
};
}
/**
* Generate new access and refresh tokens for rotation
*/
async generateRotationTokenPair(
user: { id: string; email: string; role: string },
familyId: string,
remainingSeconds: number,
deviceInfo?: DeviceInfo
): Promise<{ newAccessToken: string; newRefreshToken: string; newRefreshTokenHash: string }> {
const accessTokenId = this.generateTokenId();
const refreshTokenId = this.generateTokenId();
const accessPayload = {
sub: user.id,
email: user.email,
role: user.role || "USER",
tokenId: accessTokenId,
type: "access",
};
const newRefreshPayload: RefreshTokenPayload = {
userId: user.id,
familyId,
tokenId: refreshTokenId,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
type: "refresh",
};
const newAccessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
const newRefreshToken = await this.jwtService.sign(newRefreshPayload, remainingSeconds);
const newRefreshTokenHash = this.hashToken(newRefreshToken);
return { newAccessToken, newRefreshToken, newRefreshTokenHash };
}
generateTokenId(): string {
return randomBytes(32).toString("hex");
}
hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
parseExpiryToMs(expiry: string): number {
const unit = expiry.slice(-1);
const value = Number.parseInt(expiry.slice(0, -1));
switch (unit) {
case "s":
return value * 1000;
case "m":
return value * 60 * 1000;
case "h":
return value * 60 * 60 * 1000;
case "d":
return value * 24 * 60 * 60 * 1000;
default:
return 15 * 60 * 1000;
}
}
parseExpiryToSeconds(expiry: string): number {
return Math.floor(this.parseExpiryToMs(expiry) / 1000);
}
}

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
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";
@ -53,7 +53,7 @@ export class TokenMigrationService {
this.logger.log("Starting token migration", { dryRun }); this.logger.log("Starting token migration", { dryRun });
if (this.redis.status !== "ready") { if (this.redis.status !== "ready") {
throw new Error("Redis is not ready for migration"); throw new ServiceUnavailableException("Redis is not ready for migration");
} }
try { try {

View File

@ -0,0 +1,336 @@
import {
Injectable,
Inject,
UnauthorizedException,
ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import { TokenGeneratorService } from "./token-generator.service.js";
import { TokenStorageService } from "./token-storage.service.js";
import { TokenRevocationService } from "./token-revocation.service.js";
import { JoseJwtService } from "./jose-jwt.service.js";
interface RefreshTokenPayload extends JWTPayload {
userId: string;
familyId?: string | undefined;
tokenId: string;
deviceId?: string | undefined;
userAgent?: string | undefined;
type: "refresh";
}
interface DeviceInfo {
deviceId?: string | undefined;
userAgent?: string | undefined;
}
const ERROR_INVALID_REFRESH_TOKEN = "Invalid refresh token";
const ERROR_TOKEN_REFRESH_UNAVAILABLE = "Token refresh temporarily unavailable";
interface ValidatedTokenContext {
payload: RefreshTokenPayload;
familyId: string;
refreshTokenHash: string;
remainingSeconds: number;
absoluteExpiresAt: string;
createdAt: string;
}
/**
* Token Refresh Service
*
* Handles refresh token validation, rotation, and re-issuance.
*/
@Injectable()
export class TokenRefreshService {
private readonly allowRedisFailOpen: boolean;
constructor(
private readonly generator: TokenGeneratorService,
private readonly storage: TokenStorageService,
private readonly revocation: TokenRevocationService,
private readonly jwtService: JoseJwtService,
private readonly usersService: UsersService,
configService: ConfigService,
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger
) {
this.allowRedisFailOpen =
configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true";
}
/**
* Refresh access token using refresh token rotation
*/
async refreshTokens(
refreshToken: string,
deviceInfo?: DeviceInfo
): Promise<{ tokens: AuthTokens; user: UserAuth }> {
if (!refreshToken) {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
this.checkRedisForRefresh();
try {
const tokenContext = await this.validateAndExtractTokenContext(refreshToken);
return await this.performTokenRotation(tokenContext, deviceInfo);
} catch (error) {
return this.handleRefreshError(error);
}
}
private checkRedisForRefresh(): void {
if (!this.allowRedisFailOpen && this.redis.status !== "ready") {
this.logger.error("Redis unavailable for token refresh", {
redisStatus: this.redis.status,
});
throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE);
}
}
private async validateAndExtractTokenContext(
refreshToken: string
): Promise<ValidatedTokenContext> {
const payload = await this.verifyRefreshTokenPayload(refreshToken);
const familyId = this.extractFamilyId(payload);
const refreshTokenHash = this.generator.hashToken(refreshToken);
await this.validateStoredToken(refreshTokenHash, familyId, payload);
const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime(
familyId,
refreshTokenHash
);
return {
payload,
familyId,
refreshTokenHash,
remainingSeconds,
absoluteExpiresAt,
createdAt,
};
}
private async verifyRefreshTokenPayload(refreshToken: string): Promise<RefreshTokenPayload> {
const payload = await this.jwtService.verify<RefreshTokenPayload>(refreshToken);
if (payload.type !== "refresh") {
this.logger.warn("Token presented to refresh endpoint is not a refresh token", {
tokenId: payload.tokenId,
});
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!payload.userId || typeof payload.userId !== "string") {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!payload.tokenId || typeof payload.tokenId !== "string") {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
return payload;
}
private extractFamilyId(payload: RefreshTokenPayload): string {
return typeof payload.familyId === "string" && payload.familyId.length > 0
? payload.familyId
: payload.tokenId;
}
private async validateStoredToken(
refreshTokenHash: string,
familyId: string,
payload: RefreshTokenPayload
): Promise<void> {
const { token: storedTokenData, family: familyData } = await this.storage.getTokenAndFamily(
refreshTokenHash,
familyId
);
if (!storedTokenData) {
this.logger.warn("Refresh token not found or expired", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const tokenRecord = this.storage.parseRefreshTokenRecord(storedTokenData);
if (!tokenRecord) {
this.logger.warn("Stored refresh token payload was invalid JSON", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.storage.deleteToken(refreshTokenHash);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (tokenRecord.familyId !== familyId || tokenRecord.userId !== payload.userId) {
this.logger.warn("Refresh token record mismatch", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(tokenRecord.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!tokenRecord.valid) {
this.logger.warn("Refresh token marked as invalid", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(tokenRecord.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null;
if (family && family.tokenHash !== refreshTokenHash) {
this.logger.warn("Refresh token does not match current family token", {
familyId: familyId.slice(0, 8),
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
}
private async calculateTokenLifetime(
familyId: string,
refreshTokenHash: string
): Promise<{ remainingSeconds: number; absoluteExpiresAt: string; createdAt: string }> {
const { family: familyData } = await this.storage.getTokenAndFamily(refreshTokenHash, familyId);
const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null;
let remainingSeconds: number | null = null;
let absoluteExpiresAt: string | undefined = family?.absoluteExpiresAt;
if (absoluteExpiresAt) {
const absMs = Date.parse(absoluteExpiresAt);
if (Number.isNaN(absMs)) {
absoluteExpiresAt = undefined;
} else {
remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000));
}
}
if (remainingSeconds === null) {
remainingSeconds = await this.calculateRemainingSecondsFromTtl(familyId, refreshTokenHash);
absoluteExpiresAt = new Date(Date.now() + remainingSeconds * 1000).toISOString();
}
if (!remainingSeconds || remainingSeconds <= 0) {
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const createdAt = family?.createdAt ?? new Date().toISOString();
const expiresAt =
absoluteExpiresAt ?? new Date(Date.now() + remainingSeconds * 1000).toISOString();
return { remainingSeconds, absoluteExpiresAt: expiresAt, createdAt };
}
private async calculateRemainingSecondsFromTtl(
familyId: string,
refreshTokenHash: string
): Promise<number> {
const familyKey = `${this.storage.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`;
const tokenKey = `${this.storage.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`;
const ttl = await this.storage.getTtl(familyKey);
if (typeof ttl === "number" && ttl > 0) {
return ttl;
}
const tokenTtl = await this.storage.getTtl(tokenKey);
return typeof tokenTtl === "number" && tokenTtl > 0
? tokenTtl
: this.generator.parseExpiryToSeconds(this.generator.REFRESH_TOKEN_EXPIRY);
}
private async performTokenRotation(
context: ValidatedTokenContext,
deviceInfo?: DeviceInfo
): Promise<{ tokens: AuthTokens; user: UserAuth }> {
const user = await this.usersService.findByIdInternal(context.payload.userId);
if (!user) {
this.logger.warn("User not found during token refresh", { userId: context.payload.userId });
throw new UnauthorizedException("User not found");
}
const userProfile = mapPrismaUserToDomain(user);
const { newAccessToken, newRefreshToken, newRefreshTokenHash } =
await this.generator.generateRotationTokenPair(
user,
context.familyId,
context.remainingSeconds,
deviceInfo
);
const refreshExpiresAt =
context.absoluteExpiresAt ??
new Date(Date.now() + context.remainingSeconds * 1000).toISOString();
const rotationResult = await this.storage.atomicTokenRotation({
oldTokenHash: context.refreshTokenHash,
newTokenHash: newRefreshTokenHash,
familyId: context.familyId,
userId: user.id,
deviceInfo,
createdAt: context.createdAt,
absoluteExpiresAt: refreshExpiresAt,
ttlSeconds: context.remainingSeconds,
});
if (!rotationResult.success) {
this.logger.warn("Atomic token rotation failed - possible concurrent refresh", {
error: rotationResult.error,
familyId: context.familyId.slice(0, 8),
tokenHash: context.refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(context.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const accessExpiresAt = new Date(
Date.now() + this.generator.parseExpiryToMs(this.generator.ACCESS_TOKEN_EXPIRY)
).toISOString();
this.logger.debug("Refreshed token pair", { userId: context.payload.userId });
return {
tokens: {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresAt: accessExpiresAt,
refreshExpiresAt,
tokenType: "Bearer",
},
user: userProfile,
};
}
private handleRefreshError(error: unknown): never {
if (error instanceof UnauthorizedException || error instanceof ServiceUnavailableException) {
throw error;
}
this.logger.error("Token refresh failed with unexpected error", {
error: error instanceof Error ? error.message : String(error),
});
if (this.redis.status !== "ready") {
this.logger.error("Redis unavailable for token refresh - failing closed for security", {
redisStatus: this.redis.status,
securityReason: "refresh_token_rotation_requires_redis",
});
throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE);
}
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
}

View File

@ -1,26 +1,14 @@
import { import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
Injectable,
Inject,
UnauthorizedException,
ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { randomBytes, createHash } from "crypto";
import type { JWTPayload } from "jose"; import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth"; import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth, UserRole } from "@customer-portal/domain/customer"; import type { UserAuth, UserRole } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/application/users.service.js"; import { TokenGeneratorService } from "./token-generator.service.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import { TokenRefreshService } from "./token-refresh.service.js";
import { JoseJwtService } from "./jose-jwt.service.js";
import { TokenStorageService } from "./token-storage.service.js";
import { TokenRevocationService } from "./token-revocation.service.js"; import { TokenRevocationService } from "./token-revocation.service.js";
const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
const ERROR_INVALID_REFRESH_TOKEN = "Invalid refresh token";
const ERROR_TOKEN_REFRESH_UNAVAILABLE = "Token refresh temporarily unavailable";
export interface RefreshTokenPayload extends JWTPayload { export interface RefreshTokenPayload extends JWTPayload {
userId: string; userId: string;
/** /**
@ -38,76 +26,46 @@ export interface RefreshTokenPayload extends JWTPayload {
type: "refresh"; type: "refresh";
} }
interface DeviceInfo { export interface DeviceInfo {
deviceId?: string | undefined; deviceId?: string | undefined;
userAgent?: string | undefined; userAgent?: string | undefined;
} }
interface ValidatedTokenContext { const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
payload: RefreshTokenPayload;
familyId: string;
refreshTokenHash: string;
remainingSeconds: number;
absoluteExpiresAt: string;
createdAt: string;
}
/** /**
* Auth Token Service * Auth Token Service
* *
* Handles token generation and refresh operations. * Thin orchestrator that delegates to focused services:
* Delegates storage operations to TokenStorageService. * - TokenGeneratorService: token creation
* Delegates revocation operations to TokenRevocationService. * - TokenRefreshService: refresh + rotation logic
* - TokenRevocationService: token revocation
*
* Preserves the existing public API so consumers don't need changes.
*/ */
@Injectable() @Injectable()
export class AuthTokenService { export class AuthTokenService {
private readonly ACCESS_TOKEN_EXPIRY = "15m";
private readonly REFRESH_TOKEN_EXPIRY = "7d";
private readonly allowRedisFailOpen: boolean;
private readonly requireRedisForTokens: boolean; private readonly requireRedisForTokens: boolean;
private readonly maintenanceMode: boolean; private readonly maintenanceMode: boolean;
private readonly maintenanceMessage: string; private readonly maintenanceMessage: string;
private readonly jwtService: JoseJwtService;
private readonly configService: ConfigService;
private readonly storage: TokenStorageService;
private readonly revocation: TokenRevocationService;
private readonly redis: Redis;
private readonly logger: Logger;
private readonly usersService: UsersService;
// eslint-disable-next-line max-params
constructor( constructor(
jwtService: JoseJwtService, private readonly generator: TokenGeneratorService,
private readonly refreshService: TokenRefreshService,
private readonly revocation: TokenRevocationService,
configService: ConfigService, configService: ConfigService,
storage: TokenStorageService, @Inject("REDIS_CLIENT") private readonly redis: Redis,
revocation: TokenRevocationService, @Inject(Logger) private readonly logger: Logger
@Inject("REDIS_CLIENT") redis: Redis,
@Inject(Logger) logger: Logger,
usersService: UsersService
) { ) {
this.jwtService = jwtService;
this.configService = configService;
this.storage = storage;
this.revocation = revocation;
this.redis = redis;
this.logger = logger;
this.usersService = usersService;
this.allowRedisFailOpen =
this.configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true";
this.requireRedisForTokens = this.requireRedisForTokens =
this.configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true"; configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true";
this.maintenanceMode = this.configService.get("AUTH_MAINTENANCE_MODE", "false") === "true"; this.maintenanceMode = configService.get("AUTH_MAINTENANCE_MODE", "false") === "true";
this.maintenanceMessage = this.configService.get( this.maintenanceMessage = configService.get(
"AUTH_MAINTENANCE_MESSAGE", "AUTH_MAINTENANCE_MESSAGE",
"Authentication service is temporarily unavailable for maintenance. Please try again later." "Authentication service is temporarily unavailable for maintenance. Please try again later."
); );
} }
/**
* Check if authentication service is available
*/
private checkServiceAvailability(): void { private checkServiceAvailability(): void {
if (this.maintenanceMode) { if (this.maintenanceMode) {
this.logger.warn("Authentication service in maintenance mode", { this.logger.warn("Authentication service in maintenance mode", {
@ -129,113 +87,11 @@ export class AuthTokenService {
* Generate a new token pair with refresh token rotation * Generate a new token pair with refresh token rotation
*/ */
async generateTokenPair( async generateTokenPair(
user: { user: { id: string; email: string; role?: UserRole },
id: string;
email: string;
role?: UserRole;
},
deviceInfo?: DeviceInfo deviceInfo?: DeviceInfo
): Promise<AuthTokens> { ): Promise<AuthTokens> {
// Validate required user fields
if (!user.id || typeof user.id !== "string" || user.id.trim().length === 0) {
this.logger.error("Invalid user ID provided for token generation", {
userId: user.id,
});
throw new Error("Invalid user ID for token generation");
}
if (!user.email || typeof user.email !== "string" || user.email.trim().length === 0) {
this.logger.error("Invalid user email provided for token generation", {
userId: user.id,
});
throw new Error("Invalid user email for token generation");
}
this.checkServiceAvailability(); this.checkServiceAvailability();
return this.generator.generateTokenPair(user, deviceInfo);
const accessTokenId = this.generateTokenId();
const refreshFamilyId = this.generateTokenId();
const refreshTokenId = this.generateTokenId();
// Create access token payload
const accessPayload = {
sub: user.id,
email: user.email,
role: user.role || "USER",
tokenId: accessTokenId,
type: "access",
};
// Create refresh token payload
const refreshPayload: RefreshTokenPayload = {
userId: user.id,
familyId: refreshFamilyId,
tokenId: refreshTokenId,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
type: "refresh",
};
// Generate tokens
const accessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
const refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds);
// Store refresh token in Redis
const refreshTokenHash = this.hashToken(refreshToken);
const refreshAbsoluteExpiresAt = new Date(
Date.now() + refreshExpirySeconds * 1000
).toISOString();
// Store refresh token in Redis - this is required for secure token rotation
if (this.redis.status !== "ready") {
this.logger.error("Redis not ready for token issuance", {
status: this.redis.status,
requireRedisForTokens: this.requireRedisForTokens,
});
// Always fail if Redis is unavailable - tokens without storage cannot be
// securely rotated or revoked, creating a security vulnerability
throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE);
}
try {
await this.storage.storeRefreshToken({
userId: user.id,
familyId: refreshFamilyId,
refreshTokenHash,
deviceInfo,
refreshExpirySeconds,
absoluteExpiresAt: refreshAbsoluteExpiresAt,
});
} catch (error) {
this.logger.error("Failed to store refresh token in Redis", {
error: error instanceof Error ? error.message : String(error),
userId: user.id,
});
// Always fail on storage error - issuing tokens that can't be validated
// or rotated creates a security vulnerability
throw new ServiceUnavailableException(ERROR_SERVICE_UNAVAILABLE);
}
const accessExpiresAt = new Date(
Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)
).toISOString();
const refreshExpiresAt = refreshAbsoluteExpiresAt;
this.logger.debug("Generated new token pair", {
userId: user.id,
accessTokenId,
refreshFamilyId,
refreshTokenId,
});
return {
accessToken,
refreshToken,
expiresAt: accessExpiresAt,
refreshExpiresAt,
tokenType: "Bearer",
};
} }
/** /**
@ -245,323 +101,8 @@ export class AuthTokenService {
refreshToken: string, refreshToken: string,
deviceInfo?: DeviceInfo deviceInfo?: DeviceInfo
): Promise<{ tokens: AuthTokens; user: UserAuth }> { ): Promise<{ tokens: AuthTokens; user: UserAuth }> {
if (!refreshToken) {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
this.checkServiceAvailability(); this.checkServiceAvailability();
this.checkRedisForRefresh(); return this.refreshService.refreshTokens(refreshToken, deviceInfo);
try {
const tokenContext = await this.validateAndExtractTokenContext(refreshToken);
return await this.performTokenRotation(tokenContext, deviceInfo);
} catch (error) {
return this.handleRefreshError(error);
}
}
/**
* Check Redis availability for refresh operations
*/
private checkRedisForRefresh(): void {
if (!this.allowRedisFailOpen && this.redis.status !== "ready") {
this.logger.error("Redis unavailable for token refresh", {
redisStatus: this.redis.status,
});
throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE);
}
}
/**
* Validate refresh token and extract context for rotation
*/
private async validateAndExtractTokenContext(
refreshToken: string
): Promise<ValidatedTokenContext> {
const payload = await this.verifyRefreshTokenPayload(refreshToken);
const familyId = this.extractFamilyId(payload);
const refreshTokenHash = this.hashToken(refreshToken);
await this.validateStoredToken(refreshTokenHash, familyId, payload);
const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime(
familyId,
refreshTokenHash
);
return {
payload,
familyId,
refreshTokenHash,
remainingSeconds,
absoluteExpiresAt,
createdAt,
};
}
/**
* Verify JWT and validate payload structure
*/
private async verifyRefreshTokenPayload(refreshToken: string): Promise<RefreshTokenPayload> {
const payload = await this.jwtService.verify<RefreshTokenPayload>(refreshToken);
if (payload.type !== "refresh") {
this.logger.warn("Token presented to refresh endpoint is not a refresh token", {
tokenId: payload.tokenId,
});
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!payload.userId || typeof payload.userId !== "string") {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!payload.tokenId || typeof payload.tokenId !== "string") {
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
return payload;
}
/**
* Extract family ID from payload (supports legacy tokens)
*/
private extractFamilyId(payload: RefreshTokenPayload): string {
return typeof payload.familyId === "string" && payload.familyId.length > 0
? payload.familyId
: payload.tokenId;
}
/**
* Validate stored token data against payload
*/
private async validateStoredToken(
refreshTokenHash: string,
familyId: string,
payload: RefreshTokenPayload
): Promise<void> {
const { token: storedTokenData, family: familyData } = await this.storage.getTokenAndFamily(
refreshTokenHash,
familyId
);
if (!storedTokenData) {
this.logger.warn("Refresh token not found or expired", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const tokenRecord = this.storage.parseRefreshTokenRecord(storedTokenData);
if (!tokenRecord) {
this.logger.warn("Stored refresh token payload was invalid JSON", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.storage.deleteToken(refreshTokenHash);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (tokenRecord.familyId !== familyId || tokenRecord.userId !== payload.userId) {
this.logger.warn("Refresh token record mismatch", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(tokenRecord.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
if (!tokenRecord.valid) {
this.logger.warn("Refresh token marked as invalid", {
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(tokenRecord.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null;
if (family && family.tokenHash !== refreshTokenHash) {
this.logger.warn("Refresh token does not match current family token", {
familyId: familyId.slice(0, 8),
tokenHash: refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
}
/**
* Calculate remaining lifetime for refresh token
*/
private async calculateTokenLifetime(
familyId: string,
refreshTokenHash: string
): Promise<{ remainingSeconds: number; absoluteExpiresAt: string; createdAt: string }> {
const { family: familyData } = await this.storage.getTokenAndFamily(refreshTokenHash, familyId);
const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null;
let remainingSeconds: number | null = null;
let absoluteExpiresAt: string | undefined = family?.absoluteExpiresAt;
if (absoluteExpiresAt) {
const absMs = Date.parse(absoluteExpiresAt);
if (Number.isNaN(absMs)) {
absoluteExpiresAt = undefined;
} else {
remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000));
}
}
if (remainingSeconds === null) {
remainingSeconds = await this.calculateRemainingSecondsFromTtl(familyId, refreshTokenHash);
absoluteExpiresAt = new Date(Date.now() + remainingSeconds * 1000).toISOString();
}
if (!remainingSeconds || remainingSeconds <= 0) {
await this.revocation.invalidateTokenFamily(familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const createdAt = family?.createdAt ?? new Date().toISOString();
const expiresAt =
absoluteExpiresAt ?? new Date(Date.now() + remainingSeconds * 1000).toISOString();
return { remainingSeconds, absoluteExpiresAt: expiresAt, createdAt };
}
/**
* Calculate remaining seconds from Redis TTL
*/
private async calculateRemainingSecondsFromTtl(
familyId: string,
refreshTokenHash: string
): Promise<number> {
const familyKey = `${this.storage.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`;
const tokenKey = `${this.storage.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`;
const ttl = await this.storage.getTtl(familyKey);
if (typeof ttl === "number" && ttl > 0) {
return ttl;
}
const tokenTtl = await this.storage.getTtl(tokenKey);
return typeof tokenTtl === "number" && tokenTtl > 0
? tokenTtl
: this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
}
/**
* Perform atomic token rotation and generate new token pair
*/
private async performTokenRotation(
context: ValidatedTokenContext,
deviceInfo?: DeviceInfo
): Promise<{ tokens: AuthTokens; user: UserAuth }> {
const user = await this.usersService.findByIdInternal(context.payload.userId);
if (!user) {
this.logger.warn("User not found during token refresh", { userId: context.payload.userId });
throw new UnauthorizedException("User not found");
}
const userProfile = mapPrismaUserToDomain(user);
const { newAccessToken, newRefreshToken, newRefreshTokenHash } =
await this.generateNewTokenPair(user, context, deviceInfo);
const refreshExpiresAt =
context.absoluteExpiresAt ??
new Date(Date.now() + context.remainingSeconds * 1000).toISOString();
const rotationResult = await this.storage.atomicTokenRotation({
oldTokenHash: context.refreshTokenHash,
newTokenHash: newRefreshTokenHash,
familyId: context.familyId,
userId: user.id,
deviceInfo,
createdAt: context.createdAt,
absoluteExpiresAt: refreshExpiresAt,
ttlSeconds: context.remainingSeconds,
});
if (!rotationResult.success) {
this.logger.warn("Atomic token rotation failed - possible concurrent refresh", {
error: rotationResult.error,
familyId: context.familyId.slice(0, 8),
tokenHash: context.refreshTokenHash.slice(0, 8),
});
await this.revocation.invalidateTokenFamily(context.familyId);
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
}
const accessExpiresAt = new Date(
Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)
).toISOString();
this.logger.debug("Refreshed token pair", { userId: context.payload.userId });
return {
tokens: {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresAt: accessExpiresAt,
refreshExpiresAt,
tokenType: "Bearer",
},
user: userProfile,
};
}
/**
* Generate new access and refresh tokens
*/
private async generateNewTokenPair(
user: { id: string; email: string; role: string },
context: ValidatedTokenContext,
deviceInfo?: DeviceInfo
): Promise<{ newAccessToken: string; newRefreshToken: string; newRefreshTokenHash: string }> {
const accessTokenId = this.generateTokenId();
const refreshTokenId = this.generateTokenId();
const accessPayload = {
sub: user.id,
email: user.email,
role: user.role || "USER",
tokenId: accessTokenId,
type: "access",
};
const newRefreshPayload: RefreshTokenPayload = {
userId: user.id,
familyId: context.familyId,
tokenId: refreshTokenId,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
type: "refresh",
};
const newAccessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
const newRefreshToken = await this.jwtService.sign(newRefreshPayload, context.remainingSeconds);
const newRefreshTokenHash = this.hashToken(newRefreshToken);
return { newAccessToken, newRefreshToken, newRefreshTokenHash };
}
/**
* Handle errors during token refresh
*/
private handleRefreshError(error: unknown): never {
if (error instanceof UnauthorizedException || error instanceof ServiceUnavailableException) {
throw error;
}
this.logger.error("Token refresh failed with unexpected error", {
error: error instanceof Error ? error.message : String(error),
});
if (this.redis.status !== "ready") {
this.logger.error("Redis unavailable for token refresh - failing closed for security", {
redisStatus: this.redis.status,
securityReason: "refresh_token_rotation_requires_redis",
});
throw new ServiceUnavailableException(ERROR_TOKEN_REFRESH_UNAVAILABLE);
}
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
} }
/** /**
@ -591,34 +132,4 @@ export class AuthTokenService {
async revokeAllUserTokens(userId: string): Promise<void> { async revokeAllUserTokens(userId: string): Promise<void> {
return this.revocation.revokeAllUserTokens(userId); return this.revocation.revokeAllUserTokens(userId);
} }
private generateTokenId(): string {
return randomBytes(32).toString("hex");
}
private hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
private parseExpiryToMs(expiry: string): number {
const unit = expiry.slice(-1);
const value = Number.parseInt(expiry.slice(0, -1));
switch (unit) {
case "s":
return value * 1000;
case "m":
return value * 60 * 1000;
case "h":
return value * 60 * 60 * 1000;
case "d":
return value * 24 * 60 * 60 * 1000;
default:
return 15 * 60 * 1000;
}
}
private parseExpiryToSeconds(expiry: string): number {
return Math.floor(this.parseExpiryToMs(expiry) / 1000);
}
} }

View File

@ -2,6 +2,7 @@ import {
BadRequestException, BadRequestException,
Inject, Inject,
Injectable, Injectable,
InternalServerErrorException,
UnauthorizedException, UnauthorizedException,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
@ -88,7 +89,7 @@ export class PasswordWorkflowService {
} }
const prismaUser = await this.usersService.findByIdInternal(user.id); const prismaUser = await this.usersService.findByIdInternal(user.id);
if (!prismaUser) { if (!prismaUser) {
throw new Error("Failed to load user after password setup"); throw new InternalServerErrorException("Failed to load user after password setup");
} }
const userProfile = mapPrismaUserToDomain(prismaUser); const userProfile = mapPrismaUserToDomain(prismaUser);
@ -106,7 +107,7 @@ export class PasswordWorkflowService {
criticality: OperationCriticality.CRITICAL, criticality: OperationCriticality.CRITICAL,
context: `Set password for user ${user.id}`, context: `Set password for user ${user.id}`,
logger: this.logger, logger: this.logger,
rethrow: [NotFoundException, BadRequestException], rethrow: [NotFoundException, BadRequestException, InternalServerErrorException],
fallbackMessage: "Failed to set password", fallbackMessage: "Failed to set password",
} }
); );
@ -163,7 +164,7 @@ export class PasswordWorkflowService {
await this.usersService.update(prismaUser.id, { passwordHash }); await this.usersService.update(prismaUser.id, { passwordHash });
const freshUser = await this.usersService.findByIdInternal(prismaUser.id); const freshUser = await this.usersService.findByIdInternal(prismaUser.id);
if (!freshUser) { if (!freshUser) {
throw new Error("Failed to load user after password reset"); throw new InternalServerErrorException("Failed to load user after password reset");
} }
// Force re-login everywhere after password reset // Force re-login everywhere after password reset
await this.tokenService.revokeAllUserTokens(freshUser.id); await this.tokenService.revokeAllUserTokens(freshUser.id);
@ -174,7 +175,7 @@ export class PasswordWorkflowService {
criticality: OperationCriticality.CRITICAL, criticality: OperationCriticality.CRITICAL,
context: "Reset password", context: "Reset password",
logger: this.logger, logger: this.logger,
rethrow: [BadRequestException], rethrow: [BadRequestException, InternalServerErrorException],
fallbackMessage: "Failed to reset password", fallbackMessage: "Failed to reset password",
} }
); );
@ -221,7 +222,7 @@ export class PasswordWorkflowService {
await this.usersService.update(user.id, { passwordHash }); await this.usersService.update(user.id, { passwordHash });
const prismaUser = await this.usersService.findByIdInternal(user.id); const prismaUser = await this.usersService.findByIdInternal(user.id);
if (!prismaUser) { if (!prismaUser) {
throw new Error("Failed to load user after password change"); throw new InternalServerErrorException("Failed to load user after password change");
} }
const userProfile = mapPrismaUserToDomain(prismaUser); const userProfile = mapPrismaUserToDomain(prismaUser);
@ -251,7 +252,7 @@ export class PasswordWorkflowService {
criticality: OperationCriticality.CRITICAL, criticality: OperationCriticality.CRITICAL,
context: `Change password for user ${user.id}`, context: `Change password for user ${user.id}`,
logger: this.logger, logger: this.logger,
rethrow: [NotFoundException, BadRequestException], rethrow: [NotFoundException, BadRequestException, InternalServerErrorException],
fallbackMessage: "Failed to change password", fallbackMessage: "Failed to change password",
} }
); );

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { UsersService } from "@bff/modules/users/application/users.service.js"; import { UsersService } from "@bff/modules/users/application/users.service.js";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
@ -36,7 +36,7 @@ export class GenerateAuthResultStep {
// Load fresh user from DB // Load fresh user from DB
const freshUser = await this.usersService.findByIdInternal(userId); const freshUser = await this.usersService.findByIdInternal(userId);
if (!freshUser) { if (!freshUser) {
throw new Error("Failed to load created user"); throw new InternalServerErrorException("Failed to load created user");
} }
// Log audit event // Log audit event

View File

@ -3,6 +3,7 @@ import {
ConflictException, ConflictException,
Inject, Inject,
Injectable, Injectable,
InternalServerErrorException,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
@ -110,7 +111,7 @@ export class WhmcsLinkWorkflowService {
const prismaUser = await this.usersService.findByIdInternal(createdUser.id); const prismaUser = await this.usersService.findByIdInternal(createdUser.id);
if (!prismaUser) { if (!prismaUser) {
throw new Error("Failed to load newly linked user"); throw new InternalServerErrorException("Failed to load newly linked user");
} }
const userProfile: User = mapPrismaUserToDomain(prismaUser); const userProfile: User = mapPrismaUserToDomain(prismaUser);
@ -137,7 +138,12 @@ export class WhmcsLinkWorkflowService {
criticality: OperationCriticality.CRITICAL, criticality: OperationCriticality.CRITICAL,
context: "WHMCS account linking", context: "WHMCS account linking",
logger: this.logger, logger: this.logger,
rethrow: [BadRequestException, ConflictException, UnauthorizedException], rethrow: [
BadRequestException,
ConflictException,
InternalServerErrorException,
UnauthorizedException,
],
fallbackMessage: "Failed to link WHMCS account", fallbackMessage: "Failed to link WHMCS account",
} }
); );

View File

@ -23,6 +23,12 @@ import { GetStartedSessionService } from "../otp/get-started-session.service.js"
import { SignupUserCreationService } from "./signup/signup-user-creation.service.js"; import { SignupUserCreationService } from "./signup/signup-user-creation.service.js";
import { UpdateSalesforceFlagsStep, GenerateAuthResultStep } from "./steps/index.js"; import { UpdateSalesforceFlagsStep, GenerateAuthResultStep } from "./steps/index.js";
/** WHMCS client update payload for account migration (password + optional custom fields) */
interface WhmcsMigrationClientUpdate {
password2: string;
customfields?: string;
}
/** /**
* WHMCS Migration Workflow Service * WHMCS Migration Workflow Service
* *
@ -226,15 +232,20 @@ export class WhmcsMigrationWorkflowService {
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth; if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender; if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
const updateData: Record<string, unknown> = { const updateData: WhmcsMigrationClientUpdate = {
password2: password, password2: password,
}; };
if (Object.keys(customfieldsMap).length > 0) { if (Object.keys(customfieldsMap).length > 0) {
updateData["customfields"] = serializeWhmcsKeyValueMap(customfieldsMap); updateData.customfields = serializeWhmcsKeyValueMap(customfieldsMap);
} }
await this.whmcsClientService.updateClient(clientId, updateData); // customfields is sent as base64-encoded serialized PHP array to the WHMCS API,
// which differs from the parsed client schema type — cast is intentional
await this.whmcsClientService.updateClient(
clientId,
updateData as unknown as Parameters<typeof this.whmcsClientService.updateClient>[1]
);
this.logger.log({ clientId }, "Updated WHMCS client with new password and profile data"); this.logger.log({ clientId }, "Updated WHMCS client with new password and profile data");
} }

View File

@ -1,5 +1,6 @@
import { Injectable, UnauthorizedException, Logger } from "@nestjs/common"; import { Injectable, Inject, UnauthorizedException } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common"; import type { CanActivate, ExecutionContext } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Reflector } from "@nestjs/core"; import { Reflector } from "@nestjs/core";
import type { Request } from "express"; import type { Request } from "express";
@ -30,13 +31,12 @@ type RequestWithRoute = RequestWithCookies & {
@Injectable() @Injectable()
export class GlobalAuthGuard implements CanActivate { export class GlobalAuthGuard implements CanActivate {
private readonly logger = new Logger(GlobalAuthGuard.name);
constructor( constructor(
private reflector: Reflector, private reflector: Reflector,
private readonly tokenBlacklistService: TokenBlacklistService, private readonly tokenBlacklistService: TokenBlacklistService,
private readonly jwtService: JoseJwtService, private readonly jwtService: JoseJwtService,
private readonly usersService: UsersService private readonly usersService: UsersService,
@Inject(Logger) private readonly logger: Logger
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {

View File

@ -1,5 +1,6 @@
import { Injectable, ForbiddenException, Logger } from "@nestjs/common"; import { Injectable, Inject, ForbiddenException } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common"; import type { CanActivate, ExecutionContext } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Reflector } from "@nestjs/core"; import { Reflector } from "@nestjs/core";
import type { Request } from "express"; import type { Request } from "express";
@ -24,9 +25,10 @@ type RequestWithUser = Request & { user?: UserWithRole };
*/ */
@Injectable() @Injectable()
export class PermissionsGuard implements CanActivate { export class PermissionsGuard implements CanActivate {
private readonly logger = new Logger(PermissionsGuard.name); constructor(
private readonly reflector: Reflector,
constructor(private readonly reflector: Reflector) {} @Inject(Logger) private readonly logger: Logger
) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<Permission[] | undefined>( const requiredPermissions = this.reflector.getAllAndOverride<Permission[] | undefined>(

View File

@ -6,7 +6,12 @@
* and displayed alongside email notifications. * and displayed alongside email notifications.
*/ */
import { Injectable, Inject } from "@nestjs/common"; import {
Injectable,
Inject,
BadRequestException,
InternalServerErrorException,
} from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { PrismaService } from "@bff/infra/database/prisma.service.js"; import { PrismaService } from "@bff/infra/database/prisma.service.js";
import type { Notification as PrismaNotification } from "@prisma/client"; import type { Notification as PrismaNotification } from "@prisma/client";
@ -57,7 +62,7 @@ export class NotificationService {
async createNotification(params: CreateNotificationParams): Promise<Notification> { async createNotification(params: CreateNotificationParams): Promise<Notification> {
const template = NOTIFICATION_TEMPLATES[params.type]; const template = NOTIFICATION_TEMPLATES[params.type];
if (!template) { if (!template) {
throw new Error(`Unknown notification type: ${params.type}`); throw new BadRequestException(`Unknown notification type: ${params.type}`);
} }
// Calculate expiry date (30 days from now) // Calculate expiry date (30 days from now)
@ -116,7 +121,7 @@ export class NotificationService {
userId: params.userId, userId: params.userId,
type: params.type, type: params.type,
}); });
throw new Error("Failed to create notification"); throw new InternalServerErrorException("Failed to create notification");
} }
} }
@ -173,7 +178,7 @@ export class NotificationService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
userId, userId,
}); });
throw new Error("Failed to get notifications"); throw new InternalServerErrorException("Failed to get notifications");
} }
} }
@ -221,7 +226,7 @@ export class NotificationService {
notificationId, notificationId,
userId, userId,
}); });
throw new Error("Failed to update notification"); throw new InternalServerErrorException("Failed to update notification");
} }
} }
@ -244,7 +249,7 @@ export class NotificationService {
error: extractErrorMessage(error), error: extractErrorMessage(error),
userId, userId,
}); });
throw new Error("Failed to update notifications"); throw new InternalServerErrorException("Failed to update notifications");
} }
} }
@ -268,7 +273,7 @@ export class NotificationService {
notificationId, notificationId,
userId, userId,
}); });
throw new Error("Failed to dismiss notification"); throw new InternalServerErrorException("Failed to dismiss notification");
} }
} }

View File

@ -63,17 +63,7 @@ export class CheckoutController {
req.user?.id req.user?.id
); );
const session = await this.checkoutSessions.createSession(body, cart); return this.checkoutSessions.createSessionWithResponse(body, cart);
return {
sessionId: session.sessionId,
expiresAt: session.expiresAt,
orderType: body.orderType,
cart: {
items: cart.items,
totals: cart.totals,
},
};
} }
@Get("session/:sessionId") @Get("session/:sessionId")
@ -84,16 +74,7 @@ export class CheckoutController {
type: CheckoutSessionResponseDto, type: CheckoutSessionResponseDto,
}) })
async getSession(@Param() params: CheckoutSessionIdParamDto) { async getSession(@Param() params: CheckoutSessionIdParamDto) {
const session = await this.checkoutSessions.getSession(params.sessionId); return this.checkoutSessions.getSessionResponse(params.sessionId);
return {
sessionId: params.sessionId,
expiresAt: session.expiresAt,
orderType: session.request.orderType,
cart: {
items: session.cart.items,
totals: session.cart.totals,
},
};
} }
@Post("validate") @Post("validate")

View File

@ -5,6 +5,7 @@ import { CacheService } from "@bff/infra/cache/cache.service.js";
import type { import type {
CheckoutBuildCartRequest, CheckoutBuildCartRequest,
CheckoutCart, CheckoutCart,
CheckoutSessionResponse,
CreateOrderRequest, CreateOrderRequest,
OrderCreateResponse, OrderCreateResponse,
} from "@customer-portal/domain/orders"; } from "@customer-portal/domain/orders";
@ -62,6 +63,43 @@ export class CheckoutSessionService {
return record; return record;
} }
/**
* Create a session and return the full response with cart summary.
*/
async createSessionWithResponse(
request: CheckoutBuildCartRequest,
cart: CheckoutCart
): Promise<CheckoutSessionResponse> {
const session = await this.createSession(request, cart);
return {
sessionId: session.sessionId,
expiresAt: session.expiresAt,
orderType: request.orderType,
cart: {
items: cart.items,
totals: cart.totals,
},
};
}
/**
* Get a session and return the full response with cart summary.
*/
async getSessionResponse(sessionId: string): Promise<CheckoutSessionResponse> {
const session = await this.getSession(sessionId);
return {
sessionId,
expiresAt: session.expiresAt,
orderType: session.request.orderType,
cart: {
items: session.cart.items,
totals: session.cart.totals,
},
};
}
async deleteSession(sessionId: string): Promise<void> { async deleteSession(sessionId: string): Promise<void> {
const key = this.buildKey(sessionId); const key = this.buildKey(sessionId);
await this.cache.del(key); await this.cache.del(key);

View File

@ -3,6 +3,45 @@ import { Logger } from "nestjs-pino";
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers"; import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
import type { ContactIdentityData } from "./sim-fulfillment.service.js"; import type { ContactIdentityData } from "./sim-fulfillment.service.js";
/**
* Configuration fields extracted from checkout payload and Salesforce order records.
* Used during fulfillment for SIM activation, MNP porting, and address data.
*/
export interface FulfillmentConfigurations {
simType?: string;
eid?: string;
activationType?: string;
scheduledAt?: string;
accessMode?: string;
// MNP porting fields
isMnp?: string;
mnpNumber?: string;
mnpExpiry?: string;
mnpPhone?: string;
mvnoAccountNumber?: string;
portingFirstName?: string;
portingLastName?: string;
portingFirstNameKatakana?: string;
portingLastNameKatakana?: string;
portingGender?: string;
portingDateOfBirth?: string;
// Nested MNP object (alternative format)
mnp?: Record<string, unknown>;
// Address override from checkout
address?: Record<string, unknown>;
[key: string]: unknown;
}
/**
* Top-level payload passed into the fulfillment pipeline.
* Contains the order type and checkout configurations.
*/
export interface FulfillmentPayload {
orderType?: string;
configurations?: unknown;
[key: string]: unknown;
}
/** /**
* Fulfillment Context Mapper Service * Fulfillment Context Mapper Service
* *
@ -22,8 +61,8 @@ export class FulfillmentContextMapper {
extractConfigurations( extractConfigurations(
rawConfigurations: unknown, rawConfigurations: unknown,
sfOrder?: SalesforceOrderRecord | null sfOrder?: SalesforceOrderRecord | null
): Record<string, unknown> { ): FulfillmentConfigurations {
const config: Record<string, unknown> = {}; const config: FulfillmentConfigurations = {};
// Start with payload configurations if provided // Start with payload configurations if provided
if (rawConfigurations && typeof rawConfigurations === "object") { if (rawConfigurations && typeof rawConfigurations === "object") {
@ -49,7 +88,7 @@ export class FulfillmentContextMapper {
} }
// MNP fields // MNP fields
if (!config["isMnp"] && sfOrder.MNP_Application__c) { if (!config["isMnp"] && sfOrder.MNP_Application__c) {
config["isMnp"] = sfOrder.MNP_Application__c ? "true" : undefined; config["isMnp"] = "true";
} }
if (!config["mnpNumber"] && sfOrder.MNP_Reservation_Number__c) { if (!config["mnpNumber"] && sfOrder.MNP_Reservation_Number__c) {
config["mnpNumber"] = sfOrder.MNP_Reservation_Number__c; config["mnpNumber"] = sfOrder.MNP_Reservation_Number__c;

View File

@ -6,7 +6,10 @@ import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.
import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
import { SimFulfillmentService, type SimFulfillmentResult } from "./sim-fulfillment.service.js"; import { SimFulfillmentService, type SimFulfillmentResult } from "./sim-fulfillment.service.js";
import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js"; import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js";
import { FulfillmentContextMapper } from "./fulfillment-context-mapper.service.js"; import {
FulfillmentContextMapper,
type FulfillmentPayload,
} from "./fulfillment-context-mapper.service.js";
import type { OrderFulfillmentContext } from "./order-fulfillment-orchestrator.service.js"; import type { OrderFulfillmentContext } from "./order-fulfillment-orchestrator.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { createOrderNotes, mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers"; import { createOrderNotes, mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers";
@ -68,7 +71,7 @@ export class FulfillmentStepExecutors {
*/ */
async executeSimFulfillment( async executeSimFulfillment(
ctx: OrderFulfillmentContext, ctx: OrderFulfillmentContext,
payload: Record<string, unknown> payload: FulfillmentPayload
): Promise<SimFulfillmentResult> { ): Promise<SimFulfillmentResult> {
if (ctx.orderDetails?.orderType !== "SIM") { if (ctx.orderDetails?.orderType !== "SIM") {
return { activated: false, simType: "eSIM" as const }; return { activated: false, simType: "eSIM" as const };
@ -183,7 +186,7 @@ export class FulfillmentStepExecutors {
simFulfillmentResult?: SimFulfillmentResult simFulfillmentResult?: SimFulfillmentResult
): WhmcsOrderItemMappingResult { ): WhmcsOrderItemMappingResult {
if (!ctx.orderDetails) { if (!ctx.orderDetails) {
throw new Error("Order details are required for mapping"); throw new FulfillmentException("Order details are required for mapping");
} }
// Use domain mapper directly - single transformation! // Use domain mapper directly - single transformation!

View File

@ -9,6 +9,8 @@ import type {
} from "./order-fulfillment-orchestrator.service.js"; } from "./order-fulfillment-orchestrator.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers"; import { mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers";
import { FulfillmentException } from "@bff/core/exceptions/domain-exceptions.js";
import type { FulfillmentPayload } from "./fulfillment-context-mapper.service.js";
type WhmcsOrderItemMappingResult = ReturnType<typeof mapOrderToWhmcsItems>; type WhmcsOrderItemMappingResult = ReturnType<typeof mapOrderToWhmcsItems>;
@ -45,10 +47,7 @@ export class FulfillmentStepFactory {
* 8. sf_registration_complete * 8. sf_registration_complete
* 9. opportunity_update * 9. opportunity_update
*/ */
buildSteps( buildSteps(context: OrderFulfillmentContext, payload: FulfillmentPayload): DistributedStep[] {
context: OrderFulfillmentContext,
payload: Record<string, unknown>
): DistributedStep[] {
// Mutable state container for cross-step data // Mutable state container for cross-step data
const state: StepState = {}; const state: StepState = {};
@ -106,7 +105,7 @@ export class FulfillmentStepFactory {
private createSimFulfillmentStep( private createSimFulfillmentStep(
ctx: OrderFulfillmentContext, ctx: OrderFulfillmentContext,
payload: Record<string, unknown>, payload: FulfillmentPayload,
state: StepState state: StepState
): DistributedStep { ): DistributedStep {
return { return {
@ -163,7 +162,7 @@ export class FulfillmentStepFactory {
description: "Create order in WHMCS", description: "Create order in WHMCS",
execute: this.createTrackedStep(ctx, "whmcs_create", async () => { execute: this.createTrackedStep(ctx, "whmcs_create", async () => {
if (!state.mappingResult) { if (!state.mappingResult) {
throw new Error("Mapping result is not available"); throw new FulfillmentException("Mapping result is not available");
} }
const result = await this.executors.executeWhmcsCreate(ctx, state.mappingResult); const result = await this.executors.executeWhmcsCreate(ctx, state.mappingResult);
// eslint-disable-next-line require-atomic-updates -- Sequential step execution, state is scoped to this transaction // eslint-disable-next-line require-atomic-updates -- Sequential step execution, state is scoped to this transaction
@ -184,7 +183,7 @@ export class FulfillmentStepFactory {
description: "Accept/provision order in WHMCS", description: "Accept/provision order in WHMCS",
execute: this.createTrackedStep(ctx, "whmcs_accept", async () => { execute: this.createTrackedStep(ctx, "whmcs_accept", async () => {
if (!state.whmcsCreateResult) { if (!state.whmcsCreateResult) {
throw new Error("WHMCS create result is not available"); throw new FulfillmentException("WHMCS create result is not available");
} }
const acceptResult = await this.executors.executeWhmcsAccept(ctx, state.whmcsCreateResult); const acceptResult = await this.executors.executeWhmcsAccept(ctx, state.whmcsCreateResult);
// Update state with serviceIds from accept (services are created on accept, not on add) // Update state with serviceIds from accept (services are created on accept, not on add)

View File

@ -4,7 +4,15 @@ import type { OrderBusinessValidation, UserMapping } from "@customer-portal/doma
import { UsersService } from "@bff/modules/users/application/users.service.js"; import { UsersService } from "@bff/modules/users/application/users.service.js";
import { SalesforceOrderFieldMapService } from "@bff/integrations/salesforce/config/order-field-map.service.js"; import { SalesforceOrderFieldMapService } from "@bff/integrations/salesforce/config/order-field-map.service.js";
function assignIfString(target: Record<string, unknown>, key: string, value: unknown): void { /**
* Salesforce Order record payload.
* Keys are dynamic (resolved from SalesforceOrderFieldMap at runtime),
* so a static interface is not possible. Known static keys include
* AccountId, EffectiveDate, Status, Pricebook2Id, and OpportunityId.
*/
type SalesforceOrderFields = Record<string, unknown>;
function assignIfString(target: SalesforceOrderFields, key: string, value: unknown): void {
if (typeof value === "string" && value.trim().length > 0) { if (typeof value === "string" && value.trim().length > 0) {
target[key] = value; target[key] = value;
} }
@ -26,11 +34,11 @@ export class OrderBuilder {
userMapping: UserMapping, userMapping: UserMapping,
pricebookId: string, pricebookId: string,
userId: string userId: string
): Promise<Record<string, unknown>> { ): Promise<SalesforceOrderFields> {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const orderFieldNames = this.orderFieldMap.fields.order; const orderFieldNames = this.orderFieldMap.fields.order;
const orderFields: Record<string, unknown> = { const orderFields: SalesforceOrderFields = {
AccountId: userMapping.sfAccountId, AccountId: userMapping.sfAccountId,
EffectiveDate: today, EffectiveDate: today,
Status: "Pending Review", Status: "Pending Review",
@ -59,7 +67,7 @@ export class OrderBuilder {
} }
private addActivationFields( private addActivationFields(
orderFields: Record<string, unknown>, orderFields: SalesforceOrderFields,
body: OrderBusinessValidation, body: OrderBusinessValidation,
fieldNames: SalesforceOrderFieldMapService["fields"]["order"] fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
): void { ): void {
@ -71,7 +79,7 @@ export class OrderBuilder {
} }
private addInternetFields( private addInternetFields(
orderFields: Record<string, unknown>, orderFields: SalesforceOrderFields,
body: OrderBusinessValidation, body: OrderBusinessValidation,
fieldNames: SalesforceOrderFieldMapService["fields"]["order"] fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
): void { ): void {
@ -80,7 +88,7 @@ export class OrderBuilder {
} }
private addSimFields( private addSimFields(
orderFields: Record<string, unknown>, orderFields: SalesforceOrderFields,
body: OrderBusinessValidation, body: OrderBusinessValidation,
fieldNames: SalesforceOrderFieldMapService["fields"]["order"] fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
): void { ): void {
@ -116,7 +124,7 @@ export class OrderBuilder {
} }
private async addAddressSnapshot( private async addAddressSnapshot(
orderFields: Record<string, unknown>, orderFields: SalesforceOrderFields,
userId: string, userId: string,
body: OrderBusinessValidation, body: OrderBusinessValidation,
fieldNames: SalesforceOrderFieldMapService["fields"]["order"] fieldNames: SalesforceOrderFieldMapService["fields"]["order"]

View File

@ -8,6 +8,7 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service.
import { FulfillmentStepFactory } from "./fulfillment-step-factory.service.js"; import { FulfillmentStepFactory } from "./fulfillment-step-factory.service.js";
import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js"; import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.service.js";
import type { SimFulfillmentResult } from "./sim-fulfillment.service.js"; import type { SimFulfillmentResult } from "./sim-fulfillment.service.js";
import type { FulfillmentPayload } from "./fulfillment-context-mapper.service.js";
import { DistributedTransactionService } from "@bff/infra/database/services/distributed-transaction.service.js"; import { DistributedTransactionService } from "@bff/infra/database/services/distributed-transaction.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type { OrderDetails } from "@customer-portal/domain/orders"; import type { OrderDetails } from "@customer-portal/domain/orders";
@ -65,7 +66,7 @@ export class OrderFulfillmentOrchestrator {
*/ */
async executeFulfillment( async executeFulfillment(
sfOrderId: string, sfOrderId: string,
payload: Record<string, unknown>, payload: FulfillmentPayload,
idempotencyKey: string idempotencyKey: string
): Promise<OrderFulfillmentContext> { ): Promise<OrderFulfillmentContext> {
const context = this.initializeContext(sfOrderId, idempotencyKey, payload); const context = this.initializeContext(sfOrderId, idempotencyKey, payload);
@ -198,7 +199,7 @@ export class OrderFulfillmentOrchestrator {
private initializeContext( private initializeContext(
sfOrderId: string, sfOrderId: string,
idempotencyKey: string, idempotencyKey: string,
payload: Record<string, unknown> payload: FulfillmentPayload
): OrderFulfillmentContext { ): OrderFulfillmentContext {
const orderType = typeof payload["orderType"] === "string" ? payload["orderType"] : "Unknown"; const orderType = typeof payload["orderType"] === "string" ? payload["orderType"] : "Unknown";
return { return {

View File

@ -1,4 +1,5 @@
import { Inject, Injectable } from "@nestjs/common"; import { Inject, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { CacheService } from "@bff/infra/cache/cache.service.js"; import { CacheService } from "@bff/infra/cache/cache.service.js";
import type { OrderCreateResponse } from "@customer-portal/domain/orders"; import type { OrderCreateResponse } from "@customer-portal/domain/orders";
@ -23,13 +24,17 @@ import type { OrderCreateResponse } from "@customer-portal/domain/orders";
export class OrderIdempotencyService { export class OrderIdempotencyService {
private readonly RESULT_PREFIX = "order-result:"; private readonly RESULT_PREFIX = "order-result:";
private readonly LOCK_PREFIX = "order-lock:"; private readonly LOCK_PREFIX = "order-lock:";
private readonly RESULT_TTL_SECONDS = 86400; // 24 hours private readonly RESULT_TTL_SECONDS: number;
private readonly LOCK_TTL_SECONDS = 60; // 60 seconds for processing private readonly LOCK_TTL_SECONDS: number;
constructor( constructor(
private readonly cache: CacheService, private readonly cache: CacheService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger,
) {} config: ConfigService
) {
this.RESULT_TTL_SECONDS = config.get<number>("ORDER_RESULT_TTL_SECONDS", 86400); // 24 hours
this.LOCK_TTL_SECONDS = config.get<number>("ORDER_LOCK_TTL_SECONDS", 60); // 60 seconds for processing
}
/** /**
* Check if an order was already created for this checkout session * Check if an order was already created for this checkout session

View File

@ -2,6 +2,7 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nes
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { OrderPricebookService } from "./order-pricebook.service.js"; import { OrderPricebookService } from "./order-pricebook.service.js";
import { createOrderRequestSchema } from "@customer-portal/domain/orders"; import { createOrderRequestSchema } from "@customer-portal/domain/orders";
import { OrderValidationException } from "@bff/core/exceptions/domain-exceptions.js";
/** /**
* Handles building order items from SKU data * Handles building order items from SKU data
@ -51,7 +52,9 @@ export class OrderItemBuilder {
{ sku: normalizedSkuValue, pbeId: meta.pricebookEntryId }, { sku: normalizedSkuValue, pbeId: meta.pricebookEntryId },
"PricebookEntry missing UnitPrice" "PricebookEntry missing UnitPrice"
); );
throw new Error(`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`); throw new OrderValidationException(
`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`
);
} }
payload.push({ payload.push({

View File

@ -9,6 +9,7 @@ import {
SimActivationException, SimActivationException,
OrderValidationException, OrderValidationException,
} from "@bff/core/exceptions/domain-exceptions.js"; } from "@bff/core/exceptions/domain-exceptions.js";
import type { FulfillmentConfigurations } from "./fulfillment-context-mapper.service.js";
/** /**
* Contact identity data for PA05-05 voice option registration * Contact identity data for PA05-05 voice option registration
@ -36,7 +37,7 @@ export interface SimAssignmentDetails {
export interface SimFulfillmentRequest { export interface SimFulfillmentRequest {
orderDetails: OrderDetails; orderDetails: OrderDetails;
configurations: Record<string, unknown>; configurations: FulfillmentConfigurations;
/** Salesforce ID of the assigned Physical SIM (from Assign_Physical_SIM__c) */ /** Salesforce ID of the assigned Physical SIM (from Assign_Physical_SIM__c) */
assignedPhysicalSimId?: string; assignedPhysicalSimId?: string;
/** Voice Mail enabled from Order.SIM_Voice_Mail__c */ /** Voice Mail enabled from Order.SIM_Voice_Mail__c */
@ -548,10 +549,10 @@ export class SimFulfillmentService {
return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined; return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined;
} }
private extractMnpConfig(config: Record<string, unknown>) { private extractMnpConfig(config: FulfillmentConfigurations) {
const nested = config["mnp"]; const nested = config["mnp"];
const hasNestedMnp = nested && typeof nested === "object"; const hasNestedMnp = nested && typeof nested === "object";
const source = hasNestedMnp ? (nested as Record<string, unknown>) : config; const source = hasNestedMnp ? nested : config;
const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]); const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]);
if (isMnpFlag && isMnpFlag.toLowerCase() !== "true") { if (isMnpFlag && isMnpFlag.toLowerCase() !== "true") {

View File

@ -1,4 +1,5 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
/** /**
* Simple per-instance SSE connection limiter. * Simple per-instance SSE connection limiter.
@ -11,9 +12,13 @@ import { Injectable } from "@nestjs/common";
*/ */
@Injectable() @Injectable()
export class RealtimeConnectionLimiterService { export class RealtimeConnectionLimiterService {
private readonly maxPerUser = 3; private readonly maxPerUser: number;
private readonly counts = new Map<string, number>(); private readonly counts = new Map<string, number>();
constructor(config: ConfigService) {
this.maxPerUser = config.get<number>("REALTIME_MAX_CONNECTIONS_PER_USER", 3);
}
tryAcquire(userId: string): boolean { tryAcquire(userId: string): boolean {
const current = this.counts.get(userId) ?? 0; const current = this.counts.get(userId) ?? 0;
if (current >= this.maxPerUser) { if (current >= this.maxPerUser) {

View File

@ -1,4 +1,9 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import {
Injectable,
Inject,
BadRequestException,
InternalServerErrorException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
@ -88,7 +93,7 @@ export class InternetEligibilityService {
): Promise<string> { ): Promise<string> {
const mapping = await this.mappingsService.findByUserId(userId); const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.sfAccountId) { if (!mapping?.sfAccountId) {
throw new Error("No Salesforce mapping found for current user"); throw new BadRequestException("No Salesforce mapping found for current user");
} }
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
@ -151,7 +156,9 @@ export class InternetEligibilityService {
sfAccountId, sfAccountId,
error: extractErrorMessage(error), error: extractErrorMessage(error),
}); });
throw new Error("Failed to request availability check. Please try again later."); throw new InternalServerErrorException(
"Failed to request availability check. Please try again later."
);
} }
} }
@ -269,7 +276,7 @@ export class InternetEligibilityService {
); );
const update = this.sf.sobject("Account")?.update; const update = this.sf.sobject("Account")?.update;
if (!update) { if (!update) {
throw new Error("Salesforce Account update method not available"); throw new InternalServerErrorException("Salesforce Account update method not available");
} }
const basePayload: { Id: string } & Record<string, unknown> = { const basePayload: { Id: string } & Record<string, unknown> = {

View File

@ -18,6 +18,7 @@ import { SalesforceCaseService } from "@bff/integrations/salesforce/services/sal
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type { import type {
OrderPlacedCaseParams, OrderPlacedCaseParams,
EligibilityCheckCaseParams, EligibilityCheckCaseParams,
@ -296,7 +297,7 @@ export class WorkflowCaseManager {
filename, filename,
error: extractErrorMessage(error), error: extractErrorMessage(error),
}); });
throw new Error("Failed to create verification case"); throw new SalesforceOperationException("Failed to create verification case");
} }
} }

View File

@ -1,4 +1,5 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { import type {
CancellationPreview, CancellationPreview,
CancellationStatus, CancellationStatus,
@ -28,15 +29,14 @@ function isValidPortalStage(stage: string): stage is PortalStage {
@Injectable() @Injectable()
export class CancellationService { export class CancellationService {
private readonly logger = new Logger(CancellationService.name);
// eslint-disable-next-line max-params -- NestJS dependency injection requires all services to be injected via constructor // eslint-disable-next-line max-params -- NestJS dependency injection requires all services to be injected via constructor
constructor( constructor(
private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator, private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator,
private readonly opportunityService: SalesforceOpportunityService, private readonly opportunityService: SalesforceOpportunityService,
private readonly internetCancellation: InternetCancellationService, private readonly internetCancellation: InternetCancellationService,
private readonly simCancellation: SimCancellationService, private readonly simCancellation: SimCancellationService,
private readonly validationCoordinator: SubscriptionValidationCoordinator private readonly validationCoordinator: SubscriptionValidationCoordinator,
@Inject(Logger) private readonly logger: Logger
) {} ) {}
/** /**

View File

@ -1,5 +1,15 @@
// Notification types for SIM management (BFF-specific, not domain types) /**
export type SimNotificationContext = Record<string, unknown>; * Notification context for SIM management actions (BFF-specific, not domain types).
* Contains arbitrary action metadata (account, subscriptionId, error messages, etc.)
* that varies per action type.
*/
export interface SimNotificationContext {
account?: string;
subscriptionId?: number;
userId?: string;
error?: string;
[key: string]: unknown;
}
export interface SimActionNotification { export interface SimActionNotification {
action: string; action: string;
@ -10,3 +20,19 @@ export interface SimActionNotification {
export interface SimValidationResult { export interface SimValidationResult {
account: string; account: string;
} }
/** Debug output for SIM subscription troubleshooting (admin-only endpoint) */
export interface SimDebugInfo {
subscriptionId: number;
productName: string;
domain?: string | undefined;
orderNumber?: string | undefined;
isSimService: boolean;
groupName?: string | undefined;
status: string;
extractedAccount: string | null;
accountSource: string;
customFieldKeys: string[];
customFields?: Record<string, string> | null | undefined;
hint?: string | undefined;
}

View File

@ -5,6 +5,7 @@ import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SimValidationService } from "../sim-validation.service.js"; import { SimValidationService } from "../sim-validation.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions.js";
import type { SimTopUpRequest } from "@customer-portal/domain/sim"; import type { SimTopUpRequest } from "@customer-portal/domain/sim";
import { SimBillingService } from "../queries/sim-billing.service.js"; import { SimBillingService } from "../queries/sim-billing.service.js";
import { SimNotificationService } from "../support/sim-notification.service.js"; import { SimNotificationService } from "../support/sim-notification.service.js";
@ -272,7 +273,7 @@ export class SimTopUpService {
// to ensure consistency across all failure scenarios. // to ensure consistency across all failure scenarios.
// For manual refunds, use the WHMCS admin panel or dedicated refund endpoints. // For manual refunds, use the WHMCS admin panel or dedicated refund endpoints.
throw new Error( throw new FreebitOperationException(
`Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.` `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`
); );
} }

View File

@ -8,6 +8,7 @@ import { SimCancellationService } from "./mutations/sim-cancellation.service.js"
import { EsimManagementService } from "./mutations/esim-management.service.js"; import { EsimManagementService } from "./mutations/esim-management.service.js";
import { SimValidationService } from "./sim-validation.service.js"; import { SimValidationService } from "./sim-validation.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type { SimDebugInfo } from "../interfaces/sim-base.interface.js";
import { simInfoSchema } from "@customer-portal/domain/sim"; import { simInfoSchema } from "@customer-portal/domain/sim";
import type { import type {
SimInfo, SimInfo,
@ -182,10 +183,7 @@ export class SimOrchestrator {
/** /**
* Debug method to check subscription data for SIM services * Debug method to check subscription data for SIM services
*/ */
async debugSimSubscription( async debugSimSubscription(userId: string, subscriptionId: number): Promise<SimDebugInfo> {
userId: string,
subscriptionId: number
): Promise<Record<string, unknown>> {
return this.simValidation.debugSimSubscription(userId, subscriptionId); return this.simValidation.debugSimSubscription(userId, subscriptionId);
} }

View File

@ -2,7 +2,7 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SubscriptionsOrchestrator } from "../../subscriptions-orchestrator.service.js"; import { SubscriptionsOrchestrator } from "../../subscriptions-orchestrator.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type { SimValidationResult } from "../interfaces/sim-base.interface.js"; import type { SimValidationResult, SimDebugInfo } from "../interfaces/sim-base.interface.js";
import { import {
cleanSimAccount, cleanSimAccount,
extractSimAccountFromSubscription, extractSimAccountFromSubscription,
@ -83,10 +83,7 @@ export class SimValidationService {
/** /**
* Debug method to check subscription data for SIM services * Debug method to check subscription data for SIM services
*/ */
async debugSimSubscription( async debugSimSubscription(userId: string, subscriptionId: number): Promise<SimDebugInfo> {
userId: string,
subscriptionId: number
): Promise<Record<string, unknown>> {
try { try {
const subscription = await this.subscriptionsService.getSubscriptionById( const subscription = await this.subscriptionsService.getSubscriptionById(
userId, userId,

View File

@ -9,15 +9,20 @@ const ADMIN_EMAIL = "info@asolutions.co.jp";
const SIM_OPERATION_FAILURE_MESSAGE = "SIM operation failed. Please try again or contact support."; const SIM_OPERATION_FAILURE_MESSAGE = "SIM operation failed. Please try again or contact support.";
/** /**
* API call log structure for notification emails * API call log structure for notification emails.
* Payloads are serialized to JSON in email bodies via JSON.stringify.
*/ */
export interface ApiCallLog { export interface ApiCallLog {
url: string; url: string;
senddata?: Record<string, unknown> | string; senddata?: JsonObject | string;
json?: Record<string, unknown> | string; json?: JsonObject | string;
result: Record<string, unknown> | string; result: JsonObject | string;
} }
/** JSON-serializable object (used in API call logs for email notifications) */
type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
type JsonObject = { [key: string]: JsonValue | undefined };
/** /**
* Unified SIM notification service. * Unified SIM notification service.
* Handles all SIM-related email notifications including: * Handles all SIM-related email notifications including:
@ -326,8 +331,8 @@ Comments: ${params.comments || "N/A"}`;
/** /**
* Redact sensitive information from notification context * Redact sensitive information from notification context
*/ */
private redactSensitiveFields(context: Record<string, unknown>): Record<string, unknown> { private redactSensitiveFields(context: SimNotificationContext): SimNotificationContext {
const sanitized: Record<string, unknown> = {}; const sanitized: SimNotificationContext = {};
for (const [key, value] of Object.entries(context)) { for (const [key, value] of Object.entries(context)) {
if (typeof key === "string" && key.toLowerCase().includes("password")) { if (typeof key === "string" && key.toLowerCase().includes("password")) {
sanitized[key] = "[REDACTED]"; sanitized[key] = "[REDACTED]";

View File

@ -15,6 +15,7 @@ import { SimPlanService } from "./services/mutations/sim-plan.service.js";
import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js"; import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js";
import { EsimManagementService } from "./services/mutations/esim-management.service.js"; import { EsimManagementService } from "./services/mutations/esim-management.service.js";
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
import type { SimDebugInfo } from "./interfaces/sim-base.interface.js";
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js"; import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
import { createZodDto, ZodResponse } from "nestjs-zod"; import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
@ -126,7 +127,7 @@ export class SimController {
async debugSimSubscription( async debugSimSubscription(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param() params: SubscriptionIdParamDto @Param() params: SubscriptionIdParamDto
): Promise<Record<string, unknown>> { ): Promise<SimDebugInfo> {
return this.simOrchestrator.debugSimSubscription(req.user.id, params.id); return this.simOrchestrator.debugSimSubscription(req.user.id, params.id);
} }

View File

@ -140,7 +140,7 @@ function buildInvoiceActivities(invoices: RecentInvoice[]): Activity[] {
const activities: Activity[] = []; const activities: Activity[] = [];
for (const invoice of invoices) { for (const invoice of invoices) {
const baseMetadata: Record<string, unknown> = { const baseMetadata: Record<string, string | number | boolean | null> = {
amount: invoice.total, amount: invoice.total,
currency: invoice.currency ?? DEFAULT_CURRENCY, currency: invoice.currency ?? DEFAULT_CURRENCY,
}; };
@ -179,7 +179,7 @@ function buildInvoiceActivities(invoices: RecentInvoice[]): Activity[] {
function buildSubscriptionActivities(subscriptions: RecentSubscription[]): Activity[] { function buildSubscriptionActivities(subscriptions: RecentSubscription[]): Activity[] {
return subscriptions.map(subscription => { return subscriptions.map(subscription => {
const metadata: Record<string, unknown> = { const metadata: Record<string, string | number | boolean | null> = {
productName: subscription.productName, productName: subscription.productName,
status: subscription.status, status: subscription.status,
}; };

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq"; import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
@ -100,7 +100,9 @@ export class AddressReconcileQueueService {
error: errorMessage, error: errorMessage,
}); });
throw new Error(`Failed to queue address reconciliation: ${errorMessage}`); throw new InternalServerErrorException(
`Failed to queue address reconciliation: ${errorMessage}`
);
} }
} }

View File

@ -14,7 +14,8 @@ import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import { ResidenceCardService } from "./residence-card.service.js"; import { ResidenceCardService } from "./residence-card.service.js";
import type { ResidenceCardVerification } from "@customer-portal/domain/customer"; import type { ResidenceCardVerification } from "@customer-portal/domain/customer";
const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB
const MAX_FILE_BYTES = Number(process.env["UPLOAD_MAX_FILE_BYTES"]) || DEFAULT_MAX_FILE_BYTES;
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "application/pdf"]); const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "application/pdf"]);
type UploadedResidenceCard = { type UploadedResidenceCard = {

View File

@ -160,7 +160,7 @@ const whmcsInvoiceCommonSchema = z
companyname: s.optional(), companyname: s.optional(),
currencycode: s.optional(), currencycode: s.optional(),
}) })
.passthrough(); .strip();
export const whmcsInvoiceListItemSchema = whmcsInvoiceCommonSchema.extend({ export const whmcsInvoiceListItemSchema = whmcsInvoiceCommonSchema.extend({
id: numberLike, id: numberLike,
@ -290,6 +290,8 @@ export type WhmcsCurrency = z.infer<typeof whmcsCurrencySchema>;
export const whmcsCurrenciesResponseSchema = z export const whmcsCurrenciesResponseSchema = z
.object({ .object({
result: z.enum(["success", "error"]).optional(), result: z.enum(["success", "error"]).optional(),
message: z.string().optional(),
errorcode: z.string().optional(),
totalresults: z totalresults: z
.string() .string()
.transform(val => Number.parseInt(val, 10)) .transform(val => Number.parseInt(val, 10))
@ -300,8 +302,7 @@ export const whmcsCurrenciesResponseSchema = z
currency: z.array(whmcsCurrencySchema).or(whmcsCurrencySchema), currency: z.array(whmcsCurrencySchema).or(whmcsCurrencySchema),
}) })
.optional(), .optional(),
// Allow any additional flat currency keys for flat format
}) })
.catchall(z.string().or(z.number())); .strip();
export type WhmcsCurrenciesResponse = z.infer<typeof whmcsCurrenciesResponseSchema>; export type WhmcsCurrenciesResponse = z.infer<typeof whmcsCurrenciesResponseSchema>;

View File

@ -41,7 +41,10 @@ export const cartItemSchema = z.object({
planSku: z.string().min(1, "Plan SKU is required"), planSku: z.string().min(1, "Plan SKU is required"),
planName: z.string().min(1, "Plan name is required"), planName: z.string().min(1, "Plan name is required"),
addonSkus: z.array(z.string()).default([]), addonSkus: z.array(z.string()).default([]),
configuration: z.record(z.string(), z.unknown()).default({}), // Checkout configuration values are user-supplied key-value pairs (strings, numbers, booleans)
configuration: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.default({}),
pricing: z.object({ pricing: z.object({
monthlyTotal: z.number().nonnegative(), monthlyTotal: z.number().nonnegative(),
oneTimeTotal: z.number().nonnegative(), oneTimeTotal: z.number().nonnegative(),

View File

@ -675,6 +675,7 @@ export const apiErrorSchema = z.object({
error: z.object({ error: z.object({
code: z.string(), code: z.string(),
message: z.string(), message: z.string(),
// Intentionally z.unknown() values — error details vary by error type and may contain nested objects
details: z.record(z.string(), z.unknown()).optional(), details: z.record(z.string(), z.unknown()).optional(),
}), }),
}); });

View File

@ -13,6 +13,8 @@ import { z } from "zod";
/** /**
* Base schema for Salesforce SOQL query result * Base schema for Salesforce SOQL query result
*/ */
// Base schema uses z.unknown() for records because it is always overridden by
// salesforceResponseSchema() which extends records with the caller's typed schema.
const salesforceResponseBaseSchema = z.object({ const salesforceResponseBaseSchema = z.object({
totalSize: z.number(), totalSize: z.number(),
done: z.boolean(), done: z.boolean(),

View File

@ -26,3 +26,31 @@ export const whmcsNumberLike = z.union([z.number(), z.string()]);
* Use for WHMCS boolean flags that arrive in varying formats. * Use for WHMCS boolean flags that arrive in varying formats.
*/ */
export const whmcsBooleanLike = z.union([z.boolean(), z.number(), z.string()]); export const whmcsBooleanLike = z.union([z.boolean(), z.number(), z.string()]);
/**
* Coercing required number accepts number or numeric string, always outputs number.
* Use for WHMCS fields that must be a number but may arrive as a string.
*/
export const whmcsRequiredNumber = z.preprocess(value => {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
return value;
}, z.number());
/**
* Coercing optional number accepts number, numeric string, null, undefined, or empty string.
* Returns undefined for missing/empty values.
* Use for WHMCS fields that are optional numbers but may arrive as strings.
*/
export const whmcsOptionalNumber = z.preprocess((value): number | undefined => {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}, z.number().optional());

View File

@ -46,8 +46,7 @@ export const statusEnum = z.enum(["active", "inactive", "pending", "suspended"])
export const priorityEnum = z.enum(["low", "medium", "high", "urgent"]); export const priorityEnum = z.enum(["low", "medium", "high", "urgent"]);
export const categoryEnum = z.enum(["technical", "billing", "account", "general"]); export const categoryEnum = z.enum(["technical", "billing", "account", "general"]);
export const billingCycleEnum = z.enum(["Monthly", "Quarterly", "Annually", "Onetime", "Free"]); export const billingCycleSchema = z.enum([
export const subscriptionBillingCycleEnum = z.enum([
"Monthly", "Monthly",
"Quarterly", "Quarterly",
"Semi-Annually", "Semi-Annually",
@ -57,6 +56,7 @@ export const subscriptionBillingCycleEnum = z.enum([
"One-time", "One-time",
"Free", "Free",
]); ]);
export type BillingCycle = z.infer<typeof billingCycleSchema>;
// ============================================================================ // ============================================================================
// Salesforce and SOQL Validation Schemas // Salesforce and SOQL Validation Schemas
@ -123,6 +123,7 @@ export const apiErrorResponseSchema = z.object({
error: z.object({ error: z.object({
code: z.string(), code: z.string(),
message: z.string(), message: z.string(),
// Intentionally z.unknown() — error details vary by error type
details: z.unknown().optional(), details: z.unknown().optional(),
}), }),
}); });

View File

@ -56,7 +56,7 @@ function normalizeAddress(client: WhmcsRawClient): Address | undefined {
country: client.country ?? null, country: client.country ?? null,
countryCode: client.countrycode ?? null, countryCode: client.countrycode ?? null,
phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? null, phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? null,
phoneCountryCode: client.phonecc == null ? null : String(client.phonecc), phoneCountryCode: client.phonecc ?? null,
}); });
const hasValues = Object.values(address).some(v => v !== undefined && v !== null && v !== ""); const hasValues = Object.values(address).some(v => v !== undefined && v !== null && v !== "");

View File

@ -67,7 +67,7 @@ export const whmcsCustomFieldSchema = z
name: z.string().optional(), name: z.string().optional(),
type: z.string().optional(), type: z.string().optional(),
}) })
.passthrough(); .strip();
export const whmcsUserSchema = z export const whmcsUserSchema = z
.object({ .object({
@ -76,7 +76,7 @@ export const whmcsUserSchema = z
email: z.string(), email: z.string(),
is_owner: booleanLike.optional(), is_owner: booleanLike.optional(),
}) })
.passthrough(); .strip();
export const whmcsEmailPreferencesSchema = z export const whmcsEmailPreferencesSchema = z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()])) .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
@ -89,13 +89,13 @@ const customFieldsSchema = z
.object({ .object({
customfield: z.union([whmcsCustomFieldSchema, z.array(whmcsCustomFieldSchema)]), customfield: z.union([whmcsCustomFieldSchema, z.array(whmcsCustomFieldSchema)]),
}) })
.passthrough(), .strip(),
]) ])
.optional(); .optional();
const usersSchema = z const usersSchema = z
.object({ user: z.union([whmcsUserSchema, z.array(whmcsUserSchema)]) }) .object({ user: z.union([whmcsUserSchema, z.array(whmcsUserSchema)]) })
.passthrough() .strip()
.optional(); .optional();
export const whmcsClientSchema = z export const whmcsClientSchema = z
@ -142,7 +142,7 @@ export const whmcsClientSchema = z
customfields: customFieldsSchema, customfields: customFieldsSchema,
users: usersSchema, users: usersSchema,
}) })
.catchall(z.unknown()); .strip();
export const whmcsClientStatsSchema = z export const whmcsClientStatsSchema = z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()])) .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
@ -155,7 +155,7 @@ export const whmcsClientResponseSchema = z
client: whmcsClientSchema, client: whmcsClientSchema,
stats: whmcsClientStatsSchema, stats: whmcsClientStatsSchema,
}) })
.catchall(z.unknown()); .strip();
export type WhmcsCustomField = z.infer<typeof whmcsCustomFieldSchema>; export type WhmcsCustomField = z.infer<typeof whmcsCustomFieldSchema>;
export type WhmcsUser = z.infer<typeof whmcsUserSchema>; export type WhmcsUser = z.infer<typeof whmcsUserSchema>;

View File

@ -13,6 +13,10 @@
import { z } from "zod"; import { z } from "zod";
import { countryCodeSchema } from "../common/schema.js"; import { countryCodeSchema } from "../common/schema.js";
import {
whmcsNumberLike as numberLike,
whmcsBooleanLike as booleanLike,
} from "../common/providers/whmcs-utils/index.js";
import { import {
whmcsClientSchema as whmcsRawClientSchema, whmcsClientSchema as whmcsRawClientSchema,
whmcsCustomFieldSchema, whmcsCustomFieldSchema,
@ -23,8 +27,6 @@ import {
// ============================================================================ // ============================================================================
const stringOrNull = z.union([z.string(), z.null()]); const stringOrNull = z.union([z.string(), z.null()]);
const booleanLike = z.union([z.boolean(), z.number(), z.string()]);
const numberLike = z.union([z.number(), z.string()]);
/** /**
* Normalize boolean-like values to actual booleans * Normalize boolean-like values to actual booleans
@ -172,30 +174,33 @@ const subUserSchema = z
*/ */
const statsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(); const statsSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional();
const whmcsRawCustomFieldsArraySchema = z.array(whmcsCustomFieldSchema); const normalizeCustomFields = (input: unknown): unknown => {
if (!input) return input;
if (Array.isArray(input)) return input;
if (typeof input === "object" && input !== null && "customfield" in input) {
const cf = (input as Record<string, unknown>)["customfield"];
if (Array.isArray(cf)) return cf;
return cf ? [cf] : input;
}
return input;
};
const whmcsCustomFieldsSchema = z const whmcsCustomFieldsSchema = z
.union([ .preprocess(normalizeCustomFields, z.array(whmcsCustomFieldSchema).optional())
z.record(z.string(), z.string()),
whmcsRawCustomFieldsArraySchema,
z
.object({
customfield: z.union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema]).optional(),
})
.passthrough(),
])
.optional(); .optional();
const whmcsUsersSchema = z const normalizeUsers = (input: unknown): unknown => {
.union([ if (!input) return input;
z.array(subUserSchema), if (Array.isArray(input)) return input;
z if (typeof input === "object" && input !== null && "user" in input) {
.object({ const u = (input as Record<string, unknown>)["user"];
user: z.union([subUserSchema, z.array(subUserSchema)]).optional(), if (Array.isArray(u)) return u;
}) return u ? [u] : input;
.passthrough(), }
]) return input;
.optional(); };
const whmcsUsersSchema = z.preprocess(normalizeUsers, z.array(subUserSchema).optional()).optional();
/** /**
* WhmcsClient - Full WHMCS client data * WhmcsClient - Full WHMCS client data

View File

@ -18,7 +18,10 @@ export const activitySchema = z.object({
description: z.string().optional(), description: z.string().optional(),
date: z.string(), date: z.string(),
relatedId: z.number().optional(), relatedId: z.number().optional(),
metadata: z.record(z.string(), z.unknown()).optional(), // Activity metadata varies by activity type (invoice, service, etc.)
metadata: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.optional(),
}); });
export const invoiceActivityMetadataSchema = z export const invoiceActivityMetadataSchema = z
@ -68,7 +71,10 @@ export const dashboardSummarySchema = z.object({
export const dashboardErrorSchema = z.object({ export const dashboardErrorSchema = z.object({
code: z.string(), code: z.string(),
message: z.string(), message: z.string(),
details: z.record(z.string(), z.unknown()).optional(), // Error details vary by error type; values are primitive scalars
details: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.optional(),
}); });
export const activityFilterSchema = z.enum(["all", "billing", "orders", "support"]); export const activityFilterSchema = z.enum(["all", "billing", "orders", "support"]);

View File

@ -18,8 +18,32 @@ export const salesforceOrderItemRecordSchema = z.object({
UnitPrice: z.number().nullable().optional(), UnitPrice: z.number().nullable().optional(),
TotalPrice: z.number().nullable().optional(), TotalPrice: z.number().nullable().optional(),
PricebookEntryId: z.string().nullable().optional(), PricebookEntryId: z.string().nullable().optional(),
// Note: PricebookEntry nested object comes from catalog domain // Minimal PricebookEntry shape for fields used by the order mapper.
PricebookEntry: z.unknown().nullable().optional(), // Full schema lives in services/providers/salesforce/raw.types.ts.
PricebookEntry: z
.object({
Id: z.string(),
Product2Id: z.string().nullable().optional(),
Product2: z
.object({
Id: z.string(),
Name: z.string().optional(),
StockKeepingUnit: z.string().optional(),
Item_Class__c: z.string().nullable().optional(),
Billing_Cycle__c: z.string().nullable().optional(),
Internet_Offering_Type__c: z.string().nullable().optional(),
Internet_Plan_Tier__c: z.string().nullable().optional(),
VPN_Region__c: z.string().nullable().optional(),
Bundled_Addon__c: z.string().nullable().optional(),
Is_Bundled_Addon__c: z.boolean().nullable().optional(),
WH_Product_ID__c: z.number().nullable().optional(),
WH_Product_Name__c: z.string().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
Billing_Cycle__c: z.string().nullable().optional(), Billing_Cycle__c: z.string().nullable().optional(),
WHMCS_Service_ID__c: z.string().nullable().optional(), WHMCS_Service_ID__c: z.string().nullable().optional(),
CreatedDate: z.string().optional(), CreatedDate: z.string().optional(),
@ -114,7 +138,7 @@ export const salesforceOrderProvisionEventPayloadSchema = z
OrderId__c: z.string().optional(), OrderId__c: z.string().optional(),
OrderId: z.string().optional(), OrderId: z.string().optional(),
}) })
.passthrough(); .strip();
export type SalesforceOrderProvisionEventPayload = z.infer< export type SalesforceOrderProvisionEventPayload = z.infer<
typeof salesforceOrderProvisionEventPayloadSchema typeof salesforceOrderProvisionEventPayloadSchema
@ -128,7 +152,7 @@ export const salesforceOrderProvisionEventSchema = z
payload: salesforceOrderProvisionEventPayloadSchema, payload: salesforceOrderProvisionEventPayloadSchema,
replayId: z.number().optional(), replayId: z.number().optional(),
}) })
.passthrough(); .strip();
export type SalesforceOrderProvisionEvent = z.infer<typeof salesforceOrderProvisionEventSchema>; export type SalesforceOrderProvisionEvent = z.infer<typeof salesforceOrderProvisionEventSchema>;
@ -152,7 +176,7 @@ export const salesforcePubSubErrorMetadataSchema = z
.object({ .object({
"error-code": z.array(z.string()).optional(), "error-code": z.array(z.string()).optional(),
}) })
.passthrough(); .strip();
export type SalesforcePubSubErrorMetadata = z.infer<typeof salesforcePubSubErrorMetadataSchema>; export type SalesforcePubSubErrorMetadata = z.infer<typeof salesforcePubSubErrorMetadataSchema>;
@ -164,7 +188,7 @@ export const salesforcePubSubErrorSchema = z
details: z.string().optional(), details: z.string().optional(),
metadata: salesforcePubSubErrorMetadataSchema.optional(), metadata: salesforcePubSubErrorMetadataSchema.optional(),
}) })
.passthrough(); .strip();
export type SalesforcePubSubError = z.infer<typeof salesforcePubSubErrorSchema>; export type SalesforcePubSubError = z.infer<typeof salesforcePubSubErrorSchema>;
@ -203,6 +227,7 @@ export const salesforcePubSubCallbackSchema = z.object({
data: z.union([ data: z.union([
salesforceOrderProvisionEventSchema, salesforceOrderProvisionEventSchema,
salesforcePubSubErrorSchema, salesforcePubSubErrorSchema,
// Fallback for unknown Pub/Sub event types whose shape cannot be predicted
z.record(z.string(), z.unknown()), z.record(z.string(), z.unknown()),
z.null(), z.null(),
]), ]),

View File

@ -187,7 +187,7 @@ export const orderSelectionsSchema = z
}) })
.optional(), .optional(),
}) })
.passthrough(); .strip();
export type OrderSelections = z.infer<typeof orderSelectionsSchema>; export type OrderSelections = z.infer<typeof orderSelectionsSchema>;

View File

@ -48,7 +48,9 @@ export const whmcsPaymentGatewayRawSchema = z.object({
display_name: z.string().optional(), display_name: z.string().optional(),
type: z.string(), type: z.string(),
visible: z.union([z.boolean(), z.number(), z.string()]).optional(), visible: z.union([z.boolean(), z.number(), z.string()]).optional(),
configuration: z.record(z.string(), z.unknown()).optional(), configuration: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.optional(),
}); });
export type WhmcsPaymentGatewayRaw = z.infer<typeof whmcsPaymentGatewayRawSchema>; export type WhmcsPaymentGatewayRaw = z.infer<typeof whmcsPaymentGatewayRawSchema>;

View File

@ -47,7 +47,10 @@ export const paymentGatewaySchema = z.object({
displayName: z.string(), displayName: z.string(),
type: paymentGatewayTypeSchema, type: paymentGatewayTypeSchema,
isActive: z.boolean(), isActive: z.boolean(),
configuration: z.record(z.string(), z.unknown()).optional(), // Gateway configuration varies by provider; values are primitive scalars
configuration: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.optional(),
}); });
export const paymentGatewayListSchema = z.object({ export const paymentGatewayListSchema = z.object({

View File

@ -43,7 +43,7 @@ export interface SalesforceProductFieldMap {
export interface PricingTier { export interface PricingTier {
name: string; name: string;
price: number; price: number;
billingCycle: "Monthly" | "Onetime" | "Annual"; billingCycle: "Monthly" | "One-time" | "Annually";
description?: string; description?: string;
features?: string[]; features?: string[];
isRecommended?: boolean; isRecommended?: boolean;

View File

@ -11,27 +11,10 @@ import { z } from "zod";
import { import {
whmcsString as s, whmcsString as s,
whmcsNumberLike as numberLike, whmcsNumberLike as numberLike,
whmcsRequiredNumber as normalizeRequiredNumber,
whmcsOptionalNumber as normalizeOptionalNumber,
} from "../../../common/providers/whmcs-utils/index.js"; } from "../../../common/providers/whmcs-utils/index.js";
const normalizeRequiredNumber = z.preprocess(value => {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
return value;
}, z.number());
const normalizeOptionalNumber = z.preprocess((value): number | undefined => {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}, z.number().optional());
const optionalStringField = () => const optionalStringField = () =>
z z
.union([z.string(), z.number()]) .union([z.string(), z.number()])

View File

@ -6,6 +6,8 @@
import { z } from "zod"; import { z } from "zod";
import { billingCycleSchema } from "../common/schema.js";
// Subscription Status Schema // Subscription Status Schema
export const subscriptionStatusSchema = z.enum([ export const subscriptionStatusSchema = z.enum([
"Active", "Active",
@ -17,17 +19,8 @@ export const subscriptionStatusSchema = z.enum([
"Completed", "Completed",
]); ]);
// Subscription Cycle Schema // Subscription Cycle Schema — re-exported from common
export const subscriptionCycleSchema = z.enum([ export const subscriptionCycleSchema = billingCycleSchema;
"Monthly",
"Quarterly",
"Semi-Annually",
"Annually",
"Biennially",
"Triennially",
"One-time",
"Free",
]);
// Subscription Schema // Subscription Schema
export const subscriptionSchema = z.object({ export const subscriptionSchema = z.object({
@ -105,7 +98,8 @@ export const subscriptionStatsSchema = z.object({
*/ */
export const simActionResponseSchema = z.object({ export const simActionResponseSchema = z.object({
message: z.string(), message: z.string(),
data: z.unknown().optional(), /** Action-specific payload — varies by SIM/internet operation (top-up, cancellation, etc.) */
data: z.record(z.string(), z.unknown()).optional(),
}); });
/** /**