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:
parent
f4918b8a79
commit
b206de8dba
@ -40,8 +40,3 @@ export const getDevAuthConfig = (): DevAuthConfig => {
|
||||
skipOtp: isDevelopment && process.env["SKIP_OTP"] === "true",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use getDevAuthConfig() instead to ensure env vars are read after ConfigModule loads
|
||||
*/
|
||||
export const devAuthConfig = getDevAuthConfig();
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
* 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 type { Redis } from "ioredis";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@ -99,7 +99,7 @@ export class DistributedLockService {
|
||||
const lock = await this.acquire(key, options);
|
||||
|
||||
if (!lock) {
|
||||
throw new Error(`Unable to acquire lock for key: ${key}`);
|
||||
throw new InternalServerErrorException(`Unable to acquire lock for key: ${key}`);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { TransactionService, type TransactionOperation } from "./transaction.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
@ -331,7 +331,7 @@ export class DistributedTransactionService {
|
||||
);
|
||||
|
||||
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}]`);
|
||||
@ -360,7 +360,7 @@ export class DistributedTransactionService {
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Database transaction failed");
|
||||
throw new InternalServerErrorException(result.error || "Database transaction failed");
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
|
||||
import { InjectQueue } from "@nestjs/bullmq";
|
||||
import { Queue, Job } from "bullmq";
|
||||
import { Logger } from "nestjs-pino";
|
||||
@ -69,7 +69,7 @@ export class EmailQueueService {
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
throw new Error(`Failed to queue email: ${errorMessage}`);
|
||||
throw new InternalServerErrorException(`Failed to queue email: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>(
|
||||
endpoint: string,
|
||||
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> {
|
||||
const config = this.authService.getConfig();
|
||||
const authKey = await this.authService.getAuthKey();
|
||||
|
||||
const url = this.buildUrl(config.baseUrl, endpoint);
|
||||
const requestPayload = { ...payload, authKey };
|
||||
const logLabel = contentType === "json" ? "Freebit JSON API" : "Freebit API";
|
||||
|
||||
let attempt = 0;
|
||||
try {
|
||||
@ -42,7 +63,7 @@ export class FreebitClientService {
|
||||
async () => {
|
||||
attempt += 1;
|
||||
|
||||
this.logger.debug(`Freebit API request`, {
|
||||
this.logger.debug(`${logLabel} request`, {
|
||||
url,
|
||||
attempt,
|
||||
maxAttempts: config.retryAttempts,
|
||||
@ -52,17 +73,27 @@ export class FreebitClientService {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
||||
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, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `json=${JSON.stringify(requestPayload)}`,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const isProd = process.env["NODE_ENV"] === "production";
|
||||
const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response);
|
||||
this.logger.error("Freebit API HTTP error", {
|
||||
this.logger.error(`${logLabel} HTTP error`, {
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
@ -87,140 +118,12 @@ export class FreebitClientService {
|
||||
resultCode,
|
||||
statusCode,
|
||||
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) }),
|
||||
attempt,
|
||||
};
|
||||
this.logger.error("Freebit JSON API returned error result code", errorDetails);
|
||||
// Always log to console in dev for visibility
|
||||
if (!isProd) {
|
||||
console.error("[FREEBIT JSON API ERROR]", JSON.stringify(errorDetails, null, 2));
|
||||
}
|
||||
this.logger.error(`${logLabel} returned error response`, errorDetails);
|
||||
this.logger.debug({ errorDetails }, `${logLabel} error details`);
|
||||
|
||||
throw new FreebitError(
|
||||
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||
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;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
@ -242,7 +145,7 @@ export class FreebitClientService {
|
||||
isRetryable: error => {
|
||||
if (error instanceof FreebitError) {
|
||||
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,
|
||||
});
|
||||
this.authService.clearAuthCache();
|
||||
@ -253,7 +156,7 @@ export class FreebitClientService {
|
||||
return RetryableErrors.isTransientError(error);
|
||||
},
|
||||
logger: this.logger,
|
||||
logContext: "Freebit JSON API request",
|
||||
logContext: `${logLabel} request`,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable, Inject, Optional } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions.js";
|
||||
import type {
|
||||
FreebitAccountDetailsResponse,
|
||||
FreebitTrafficInfoResponse,
|
||||
@ -72,7 +73,7 @@ export class FreebitMapperService {
|
||||
async mapToSimDetails(response: FreebitAccountDetailsResponse): Promise<SimDetails> {
|
||||
const account = response.responseDatas[0];
|
||||
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
|
||||
@ -212,7 +213,7 @@ export class FreebitMapperService {
|
||||
*/
|
||||
mapToSimUsage(response: FreebitTrafficInfoResponse): SimUsage {
|
||||
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;
|
||||
@ -237,7 +238,7 @@ export class FreebitMapperService {
|
||||
*/
|
||||
mapToSimTopUpHistory(response: FreebitQuotaHistoryResponse, account: string): SimTopUpHistory {
|
||||
if (!response.quotaHistory) {
|
||||
throw new Error("No history data in response");
|
||||
throw new FreebitOperationException("No history data in response");
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -9,10 +9,16 @@
|
||||
* JAPAN_POST_CLIENT_SECRET - OAuth client secret
|
||||
*
|
||||
* 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 { Logger } from "nestjs-pino";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
@ -26,6 +32,7 @@ interface JapanPostConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
timeout: number;
|
||||
defaultClientIp: string;
|
||||
}
|
||||
|
||||
interface ConfigValidationError {
|
||||
@ -58,6 +65,8 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
clientId: this.configService.get<string>("JAPAN_POST_CLIENT_ID") || "",
|
||||
clientSecret: this.configService.get<string>("JAPAN_POST_CLIENT_SECRET") || "",
|
||||
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
|
||||
@ -159,7 +168,7 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"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({
|
||||
grant_type: "client_credentials",
|
||||
@ -186,7 +195,7 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
apiMessage: parsedError?.message,
|
||||
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;
|
||||
@ -213,11 +222,13 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
timeoutMs: this.config.timeout,
|
||||
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)
|
||||
if (!(error instanceof Error && error.message.startsWith("Token request failed"))) {
|
||||
if (!(error instanceof InternalServerErrorException)) {
|
||||
this.logger.error("Japan Post token request error", {
|
||||
endpoint: tokenUrl,
|
||||
error: extractErrorMessage(error),
|
||||
@ -298,7 +309,8 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
* @param clientIp - Client IP address for x-forwarded-for header
|
||||
* @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 controller = new AbortController();
|
||||
@ -314,7 +326,7 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-forwarded-for": clientIp,
|
||||
"x-forwarded-for": ip,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
@ -337,7 +349,7 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
apiMessage: parsedError?.message,
|
||||
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();
|
||||
@ -360,11 +372,13 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
timeoutMs: this.config.timeout,
|
||||
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)
|
||||
if (!(error instanceof Error && error.message.startsWith("ZIP code search failed"))) {
|
||||
if (!(error instanceof InternalServerErrorException)) {
|
||||
this.logger.error("Japan Post ZIP search error", {
|
||||
zipCode,
|
||||
endpoint: url,
|
||||
|
||||
@ -16,6 +16,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
|
||||
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 { parseNumRequested } from "./pubsub.utils.js";
|
||||
|
||||
@ -63,7 +64,9 @@ export class PubSubClientService implements OnModuleDestroy {
|
||||
const instanceUrl = this.sfConnection.getInstanceUrl();
|
||||
|
||||
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 =
|
||||
|
||||
@ -10,6 +10,7 @@ import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "../salesforce-connection.service.js";
|
||||
import { assertSalesforceId } from "../../utils/soql.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_FIELD_MAP } from "../../constants/index.js";
|
||||
import {
|
||||
@ -19,6 +20,13 @@ import {
|
||||
requireStringField,
|
||||
} 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()
|
||||
export class OpportunityCancellationService {
|
||||
constructor(
|
||||
@ -43,7 +51,8 @@ export class OpportunityCancellationService {
|
||||
|
||||
const safeData = (() => {
|
||||
const unknownData: unknown = data;
|
||||
if (!isRecord(unknownData)) throw new Error("Invalid cancellation data");
|
||||
if (!isRecord(unknownData))
|
||||
throw new SalesforceOperationException("Invalid cancellation data");
|
||||
|
||||
return {
|
||||
scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"),
|
||||
@ -58,7 +67,7 @@ export class OpportunityCancellationService {
|
||||
cancellationNotice: safeData.cancellationNotice,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
const payload: SalesforceOpportunityPayload = {
|
||||
Id: safeOppId,
|
||||
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
|
||||
[OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: safeData.scheduledCancellationDate,
|
||||
@ -69,7 +78,9 @@ export class OpportunityCancellationService {
|
||||
try {
|
||||
const updateMethod = this.sf.sobject("Opportunity").update;
|
||||
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 });
|
||||
@ -83,7 +94,7 @@ export class OpportunityCancellationService {
|
||||
error: extractErrorMessage(error),
|
||||
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 unknownData: unknown = data;
|
||||
if (!isRecord(unknownData)) throw new Error("Invalid SIM cancellation data");
|
||||
if (!isRecord(unknownData))
|
||||
throw new SalesforceOperationException("Invalid SIM cancellation data");
|
||||
|
||||
return {
|
||||
scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"),
|
||||
@ -117,7 +129,7 @@ export class OpportunityCancellationService {
|
||||
cancellationNotice: safeData.cancellationNotice,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
const payload: SalesforceOpportunityPayload = {
|
||||
Id: safeOppId,
|
||||
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
|
||||
[OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate]: safeData.scheduledCancellationDate,
|
||||
@ -127,7 +139,9 @@ export class OpportunityCancellationService {
|
||||
try {
|
||||
const updateMethod = this.sf.sobject("Opportunity").update;
|
||||
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 });
|
||||
@ -141,7 +155,7 @@ export class OpportunityCancellationService {
|
||||
error: extractErrorMessage(error),
|
||||
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,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
const payload: SalesforceOpportunityPayload = {
|
||||
Id: safeOppId,
|
||||
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLED,
|
||||
};
|
||||
@ -163,7 +177,9 @@ export class OpportunityCancellationService {
|
||||
try {
|
||||
const updateMethod = this.sf.sobject("Opportunity").update;
|
||||
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 });
|
||||
@ -176,7 +192,7 @@ export class OpportunityCancellationService {
|
||||
error: extractErrorMessage(error),
|
||||
opportunityId: safeOppId,
|
||||
});
|
||||
throw new Error("Failed to mark cancellation complete");
|
||||
throw new SalesforceOperationException("Failed to mark cancellation complete");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "../salesforce-connection.service.js";
|
||||
import { assertSalesforceId } from "../../utils/soql.util.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
|
||||
import {
|
||||
type OpportunityStageValue,
|
||||
type OpportunityProductTypeValue,
|
||||
@ -22,6 +23,13 @@ import {
|
||||
} from "@customer-portal/domain/opportunity";
|
||||
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()
|
||||
export class OpportunityMutationService {
|
||||
private readonly opportunityRecordTypeIds: Partial<
|
||||
@ -66,7 +74,7 @@ export class OpportunityMutationService {
|
||||
const commodityType = getDefaultCommodityType(request.productType);
|
||||
|
||||
const recordTypeId = this.resolveOpportunityRecordTypeId(request.productType);
|
||||
const payload: Record<string, unknown> = {
|
||||
const payload: SalesforceOpportunityPayload = {
|
||||
[OPPORTUNITY_FIELD_MAP.name]: opportunityName,
|
||||
[OPPORTUNITY_FIELD_MAP.accountId]: safeAccountId,
|
||||
[OPPORTUNITY_FIELD_MAP.stage]: request.stage,
|
||||
@ -83,13 +91,15 @@ export class OpportunityMutationService {
|
||||
try {
|
||||
const createMethod = this.sf.sobject("Opportunity").create;
|
||||
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 };
|
||||
|
||||
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", {
|
||||
@ -116,7 +126,7 @@ export class OpportunityMutationService {
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
const payload: SalesforceOpportunityPayload = {
|
||||
Id: safeOppId,
|
||||
[OPPORTUNITY_FIELD_MAP.stage]: stage,
|
||||
};
|
||||
@ -144,7 +154,9 @@ export class OpportunityMutationService {
|
||||
try {
|
||||
const updateMethod = this.sf.sobject("Opportunity").update;
|
||||
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 });
|
||||
@ -159,7 +171,7 @@ export class OpportunityMutationService {
|
||||
opportunityId: safeOppId,
|
||||
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,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
const payload: SalesforceOpportunityPayload = {
|
||||
Id: safeOppId,
|
||||
[OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId,
|
||||
[OPPORTUNITY_FIELD_MAP.whmcsRegistrationUrl]: `productselect=${whmcsServiceId}`,
|
||||
@ -186,7 +198,9 @@ export class OpportunityMutationService {
|
||||
try {
|
||||
const updateMethod = this.sf.sobject("Opportunity").update;
|
||||
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 });
|
||||
@ -220,7 +234,7 @@ export class OpportunityMutationService {
|
||||
try {
|
||||
const updateMethod = this.sf.sobject("Order").update;
|
||||
if (!updateMethod) {
|
||||
throw new Error("Salesforce Order update method not available");
|
||||
throw new SalesforceOperationException("Salesforce Order update method not available");
|
||||
}
|
||||
|
||||
await updateMethod({
|
||||
|
||||
@ -2,11 +2,20 @@ import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
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 type { SalesforceAccountRecord } from "@customer-portal/domain/customer";
|
||||
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
|
||||
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
|
||||
*
|
||||
@ -59,7 +68,7 @@ export class SalesforceAccountService {
|
||||
this.logger.error("Failed to find account by customer number", {
|
||||
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", {
|
||||
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,
|
||||
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 accountPayload: Record<string, unknown> = {
|
||||
const accountPayload: SalesforceAccountPayload = {
|
||||
// Person Account fields (required for Person Accounts)
|
||||
FirstName: data.firstName,
|
||||
LastName: data.lastName,
|
||||
@ -191,7 +200,7 @@ export class SalesforceAccountService {
|
||||
PersonMobilePhone: data.phone,
|
||||
// Record type for Person Accounts (required)
|
||||
RecordTypeId: personAccountRecordTypeId,
|
||||
// Portal tracking fields
|
||||
// Portal tracking fields (dynamic keys from ConfigService)
|
||||
[this.portalStatusField]: data.portalStatus ?? "Not Yet",
|
||||
[this.portalSourceField]: data.portalSource,
|
||||
};
|
||||
@ -206,13 +215,15 @@ export class SalesforceAccountService {
|
||||
try {
|
||||
const createMethod = this.connection.sobject("Account").create;
|
||||
if (!createMethod) {
|
||||
throw new Error("Salesforce create method not available");
|
||||
throw new SalesforceOperationException("Salesforce create method not available");
|
||||
}
|
||||
|
||||
const result = await createMethod(accountPayload);
|
||||
|
||||
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;
|
||||
@ -275,7 +286,7 @@ export class SalesforceAccountService {
|
||||
},
|
||||
"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 }>;
|
||||
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
const record = recordTypeQuery.records[0];
|
||||
if (!record) {
|
||||
throw new Error("Person Account RecordType record not found");
|
||||
throw new SalesforceOperationException("Person Account RecordType record not found");
|
||||
}
|
||||
const recordTypeId = record.Id;
|
||||
this.logger.debug("Found Person Account RecordType", {
|
||||
@ -314,7 +325,7 @@ export class SalesforceAccountService {
|
||||
this.logger.error("Failed to query Person Account RecordType", {
|
||||
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;
|
||||
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
|
||||
const contactPayload: Record<string, unknown> = {
|
||||
const contactPayload: SalesforceContactPayload = {
|
||||
Id: personContactId,
|
||||
MobilePhone: data.phone,
|
||||
Sex__c: mapGenderToSalesforce(data.gender),
|
||||
@ -362,7 +373,7 @@ export class SalesforceAccountService {
|
||||
|
||||
const updateMethod = this.connection.sobject("Contact").update;
|
||||
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 });
|
||||
@ -374,7 +385,7 @@ export class SalesforceAccountService {
|
||||
error: extractErrorMessage(error),
|
||||
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
|
||||
const contactPayload: Record<string, unknown> = {
|
||||
const contactPayload: SalesforceContactPayload = {
|
||||
Id: personContactId,
|
||||
MailingStreet: address.mailingStreet || "",
|
||||
MailingCity: address.mailingCity,
|
||||
@ -466,7 +477,7 @@ export class SalesforceAccountService {
|
||||
update: SalesforceAccountPortalUpdate
|
||||
): Promise<void> {
|
||||
const validAccountId = salesforceIdSchema.parse(accountId);
|
||||
const payload: Record<string, unknown> = { Id: validAccountId };
|
||||
const payload: SalesforceAccountPayload = { Id: validAccountId };
|
||||
|
||||
if (update.status) {
|
||||
payload[this.portalStatusField] = update.status;
|
||||
|
||||
@ -16,6 +16,7 @@ import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service.js";
|
||||
import { assertSalesforceId } from "../utils/soql.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 type { SalesforceResponse } from "@customer-portal/domain/common/providers";
|
||||
import type { SupportCase, CaseMessageList } from "@customer-portal/domain/support";
|
||||
@ -44,6 +45,13 @@ import {
|
||||
// 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.
|
||||
*
|
||||
@ -117,7 +125,7 @@ export class SalesforceCaseService {
|
||||
error: extractErrorMessage(error),
|
||||
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),
|
||||
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)
|
||||
: SALESFORCE_CASE_PRIORITY.MEDIUM;
|
||||
|
||||
const casePayload: Record<string, unknown> = {
|
||||
const casePayload: SalesforceCasePayload = {
|
||||
[CASE_FIELDS.origin]: params.origin,
|
||||
[CASE_FIELDS.status]: SALESFORCE_CASE_STATUS.NEW,
|
||||
[CASE_FIELDS.priority]: sfPriority,
|
||||
@ -221,7 +229,7 @@ export class SalesforceCaseService {
|
||||
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
|
||||
|
||||
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
|
||||
@ -241,7 +249,7 @@ export class SalesforceCaseService {
|
||||
accountIdTail: safeAccountId.slice(-4),
|
||||
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 }> {
|
||||
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.status]: SALESFORCE_CASE_STATUS.NEW,
|
||||
[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 };
|
||||
|
||||
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
|
||||
@ -288,7 +296,7 @@ export class SalesforceCaseService {
|
||||
error: extractErrorMessage(error),
|
||||
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),
|
||||
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 },
|
||||
"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", {
|
||||
@ -434,7 +442,7 @@ export class SalesforceCaseService {
|
||||
};
|
||||
|
||||
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();
|
||||
@ -450,7 +458,7 @@ export class SalesforceCaseService {
|
||||
error: extractErrorMessage(error),
|
||||
caseId: safeCaseId,
|
||||
});
|
||||
throw new Error("Failed to add comment");
|
||||
throw new SalesforceOperationException("Failed to add comment");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service.js";
|
||||
import { assertSalesforceId, buildInClause } from "../utils/soql.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 {
|
||||
SalesforceOrderItemRecord,
|
||||
@ -28,6 +29,13 @@ import {
|
||||
import type { SalesforceResponse } from "@customer-portal/domain/common/providers";
|
||||
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
|
||||
*
|
||||
@ -114,7 +122,7 @@ export class SalesforceOrderService {
|
||||
/**
|
||||
* 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;
|
||||
this.logger.log({ orderType: orderFields[typeField] }, "Creating Salesforce Order");
|
||||
|
||||
@ -132,7 +140,7 @@ export class SalesforceOrderService {
|
||||
}
|
||||
|
||||
async createOrderWithItems(
|
||||
orderFields: Record<string, unknown>,
|
||||
orderFields: SalesforceOrderPayload,
|
||||
items: Array<{ pricebookEntryId: string; unitPrice: number; quantity: number; sku?: string }>
|
||||
): Promise<{ id: string }> {
|
||||
if (!items.length) {
|
||||
@ -172,7 +180,7 @@ export class SalesforceOrderService {
|
||||
.map(err => `[${err.statusCode}] ${err.message}`)
|
||||
.join("; ");
|
||||
|
||||
throw new Error(
|
||||
throw new SalesforceOperationException(
|
||||
errorDetails || "Salesforce composite tree returned errors during order creation"
|
||||
);
|
||||
}
|
||||
@ -182,7 +190,9 @@ export class SalesforceOrderService {
|
||||
);
|
||||
|
||||
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(
|
||||
|
||||
@ -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()
|
||||
export class SalesforceSIMInventoryService {
|
||||
constructor(
|
||||
@ -193,20 +202,20 @@ export class SalesforceSIMInventoryService {
|
||||
});
|
||||
|
||||
try {
|
||||
const updatePayload: Record<string, unknown> = {
|
||||
const updatePayload: SimInventoryUpdatePayload = {
|
||||
Id: safeId,
|
||||
Status__c: SIM_INVENTORY_STATUS.ASSIGNED,
|
||||
};
|
||||
|
||||
// Add optional assignment fields if provided
|
||||
if (details?.accountId) {
|
||||
updatePayload["Assigned_Account__c"] = details.accountId;
|
||||
updatePayload.Assigned_Account__c = details.accountId;
|
||||
}
|
||||
if (details?.orderId) {
|
||||
updatePayload["Assigned_Order__c"] = details.orderId;
|
||||
updatePayload.Assigned_Order__c = details.orderId;
|
||||
}
|
||||
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 });
|
||||
|
||||
@ -16,15 +16,11 @@ export class WhmcsErrorHandlerService {
|
||||
/**
|
||||
* Handle WHMCS API error response
|
||||
*/
|
||||
handleApiError(
|
||||
errorResponse: WhmcsErrorResponse,
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): never {
|
||||
handleApiError(errorResponse: WhmcsErrorResponse, action: string): never {
|
||||
const message = errorResponse.message;
|
||||
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);
|
||||
}
|
||||
|
||||
@ -172,8 +168,7 @@ export class WhmcsErrorHandlerService {
|
||||
private mapProviderErrorToDomain(
|
||||
action: string,
|
||||
message: string,
|
||||
providerErrorCode: string | undefined,
|
||||
params: Record<string, unknown>
|
||||
providerErrorCode: string | undefined
|
||||
): { code: ErrorCodeType; status: HttpStatus } {
|
||||
// 1) ValidateLogin: user credentials are wrong (expected)
|
||||
if (
|
||||
@ -199,7 +194,6 @@ export class WhmcsErrorHandlerService {
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
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 type { WhmcsResponse } from "@customer-portal/domain/common/providers";
|
||||
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);
|
||||
@ -242,7 +243,7 @@ export class WhmcsHttpClientService {
|
||||
parseError: extractErrorMessage(parseError),
|
||||
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
|
||||
@ -255,7 +256,7 @@ export class WhmcsHttpClientService {
|
||||
: { responseText: responseText.slice(0, 500) }),
|
||||
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
|
||||
|
||||
@ -37,12 +37,48 @@ import type {
|
||||
WhmcsProductListResponse,
|
||||
} from "@customer-portal/domain/subscriptions/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 {
|
||||
WhmcsRequestOptions,
|
||||
WhmcsConnectionStats,
|
||||
} 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
|
||||
*
|
||||
@ -105,7 +141,7 @@ export class WhmcsConnectionFacade implements OnModuleInit {
|
||||
|
||||
if (response.result === "error") {
|
||||
const errorResponse = response as WhmcsErrorResponse;
|
||||
this.errorHandler.handleApiError(errorResponse, action, params);
|
||||
this.errorHandler.handleApiError(errorResponse, action);
|
||||
}
|
||||
|
||||
return response.data as T;
|
||||
@ -189,7 +225,7 @@ export class WhmcsConnectionFacade implements OnModuleInit {
|
||||
|
||||
async updateClient(
|
||||
clientId: number,
|
||||
updateData: Record<string, unknown>
|
||||
updateData: WhmcsUpdateClientParams
|
||||
): Promise<{ result: string }> {
|
||||
return this.makeRequest<{ result: string }>("UpdateClient", {
|
||||
clientid: clientId,
|
||||
@ -244,11 +280,11 @@ export class WhmcsConnectionFacade implements OnModuleInit {
|
||||
// ORDER OPERATIONS (Used by order services)
|
||||
// ==========================================
|
||||
|
||||
async addOrder(params: Record<string, unknown>) {
|
||||
async addOrder(params: WhmcsAddOrderPayload) {
|
||||
return this.makeRequest("AddOrder", params);
|
||||
}
|
||||
|
||||
async getOrders(params: Record<string, unknown> = {}) {
|
||||
async getOrders(params: WhmcsGetOrdersParams = {}) {
|
||||
return this.makeRequest("GetOrders", params);
|
||||
}
|
||||
|
||||
@ -323,10 +359,6 @@ export class WhmcsConnectionFacade implements OnModuleInit {
|
||||
return this.configService.getBaseUrl();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// UTILITY METHODS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Determine request priority based on action type
|
||||
*/
|
||||
|
||||
@ -114,7 +114,7 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
|
||||
const response = (await this.connectionService.getCurrencies()) as WhmcsCurrenciesResponse;
|
||||
|
||||
// Check if response has currencies data (success case) or error fields
|
||||
if (response.result === "success" || (response.currencies && !response["error"])) {
|
||||
if (response.result === "success" || (response.currencies && response.result !== "error")) {
|
||||
// Parse the WHMCS response format into currency objects
|
||||
this.currencies = this.parseWhmcsCurrenciesResponse(response);
|
||||
|
||||
@ -135,13 +135,12 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
|
||||
} else {
|
||||
this.logger.error("WHMCS GetCurrencies returned error", {
|
||||
result: response?.result,
|
||||
message: response?.["message"],
|
||||
error: response?.["error"],
|
||||
errorcode: response?.["errorcode"],
|
||||
message: response?.message,
|
||||
errorcode: response?.errorcode,
|
||||
fullResponse: JSON.stringify(response, null, 2),
|
||||
});
|
||||
throw new WhmcsOperationException(
|
||||
`WHMCS GetCurrencies error: ${response?.["message"] || response?.["error"] || "Unknown error"}`,
|
||||
`WHMCS GetCurrencies error: ${response?.message || "Unknown error"}`,
|
||||
{ operation: "getCurrencies" }
|
||||
);
|
||||
}
|
||||
@ -187,7 +186,9 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
} else {
|
||||
// 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]")
|
||||
);
|
||||
|
||||
@ -203,12 +204,12 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy {
|
||||
// Build currency objects from the flat response
|
||||
for (const index of currencyIndices) {
|
||||
const currency: Currency = {
|
||||
id: Number.parseInt(String(response[`currencies[currency][${index}][id]`])) || 0,
|
||||
code: String(response[`currencies[currency][${index}][code]`] || ""),
|
||||
prefix: String(response[`currencies[currency][${index}][prefix]`] || ""),
|
||||
suffix: String(response[`currencies[currency][${index}][suffix]`] || ""),
|
||||
format: String(response[`currencies[currency][${index}][format]`] || "1"),
|
||||
rate: String(response[`currencies[currency][${index}][rate]`] || "1.00000"),
|
||||
id: Number.parseInt(String(flat[`currencies[currency][${index}][id]`])) || 0,
|
||||
code: String(flat[`currencies[currency][${index}][code]`] ?? ""),
|
||||
prefix: String(flat[`currencies[currency][${index}][prefix]`] ?? ""),
|
||||
suffix: String(flat[`currencies[currency][${index}][suffix]`] ?? ""),
|
||||
format: String(flat[`currencies[currency][${index}][format]`] ?? "1"),
|
||||
rate: String(flat[`currencies[currency][${index}][rate]`] ?? "1.00000"),
|
||||
};
|
||||
|
||||
// Validate that we have essential currency data
|
||||
|
||||
@ -4,20 +4,39 @@ import { WhmcsConnectionFacade } from "../facades/whmcs.facade.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
|
||||
|
||||
import type {
|
||||
WhmcsOrderItem,
|
||||
WhmcsAddOrderParams,
|
||||
WhmcsAddOrderResponse,
|
||||
WhmcsOrderResult,
|
||||
} from "@customer-portal/domain/orders/providers";
|
||||
import {
|
||||
buildWhmcsAddOrderPayload,
|
||||
whmcsAddOrderResponseSchema,
|
||||
whmcsAcceptOrderResponseSchema,
|
||||
type WhmcsOrderItem,
|
||||
type WhmcsAddOrderParams,
|
||||
type WhmcsAddOrderResponse,
|
||||
type WhmcsOrderResult,
|
||||
type WhmcsAddOrderPayload,
|
||||
type WhmcsAcceptOrderResponse,
|
||||
} from "@customer-portal/domain/orders/providers";
|
||||
|
||||
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()
|
||||
export class WhmcsOrderService {
|
||||
constructor(
|
||||
@ -47,16 +66,14 @@ export class WhmcsOrderService {
|
||||
|
||||
this.logger.debug("Built WHMCS AddOrder payload", {
|
||||
clientId: params.clientId,
|
||||
productCount: Array.isArray(addOrderPayload["pid"])
|
||||
? (addOrderPayload["pid"] as unknown[]).length
|
||||
: 0,
|
||||
pids: addOrderPayload["pid"],
|
||||
quantities: addOrderPayload["qty"], // CRITICAL: Must be included for products to be added
|
||||
billingCycles: addOrderPayload["billingcycle"],
|
||||
hasConfigOptions: Boolean(addOrderPayload["configoptions"]),
|
||||
hasCustomFields: Boolean(addOrderPayload["customfields"]),
|
||||
promoCode: addOrderPayload["promocode"],
|
||||
paymentMethod: addOrderPayload["paymentmethod"],
|
||||
productCount: addOrderPayload.pid.length,
|
||||
pids: addOrderPayload.pid,
|
||||
quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added
|
||||
billingCycles: addOrderPayload.billingcycle,
|
||||
hasConfigOptions: Boolean(addOrderPayload.configoptions),
|
||||
hasCustomFields: Boolean(addOrderPayload.customfields),
|
||||
promoCode: addOrderPayload.promocode,
|
||||
paymentMethod: addOrderPayload.paymentmethod,
|
||||
});
|
||||
|
||||
// Call WHMCS AddOrder API
|
||||
@ -135,7 +152,7 @@ export class WhmcsOrderService {
|
||||
// Call WHMCS AcceptOrder API
|
||||
// Note: The HTTP client throws errors automatically if result === "error"
|
||||
// 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
|
||||
this.logger.debug("WHMCS AcceptOrder response", {
|
||||
@ -248,15 +265,14 @@ export class WhmcsOrderService {
|
||||
/**
|
||||
* Get order details from WHMCS
|
||||
*/
|
||||
async getOrderDetails(orderId: number): Promise<Record<string, unknown> | null> {
|
||||
async getOrderDetails(orderId: number): Promise<WhmcsGetOrdersOrder | null> {
|
||||
try {
|
||||
// Note: The HTTP client throws errors automatically if result === "error"
|
||||
const response = (await this.connection.getOrders({
|
||||
id: orderId.toString(),
|
||||
})) as Record<string, unknown>;
|
||||
})) as WhmcsGetOrdersResponse;
|
||||
|
||||
const orders = response["orders"] as { order?: Record<string, unknown>[] } | undefined;
|
||||
return orders?.order?.[0] ?? null;
|
||||
return response.orders?.order?.[0] ?? null;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get WHMCS order details", {
|
||||
error: extractErrorMessage(error),
|
||||
@ -302,19 +318,19 @@ export class WhmcsOrderService {
|
||||
*
|
||||
* Delegates to shared mapper function from integration package
|
||||
*/
|
||||
private buildAddOrderPayload(params: WhmcsAddOrderParams): Record<string, unknown> {
|
||||
private buildAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAddOrderPayload {
|
||||
const payload = buildWhmcsAddOrderPayload(params);
|
||||
|
||||
this.logger.debug("Built WHMCS AddOrder payload", {
|
||||
clientId: params.clientId,
|
||||
productCount: params.items.length,
|
||||
pids: payload["pid"],
|
||||
billingCycles: payload["billingcycle"],
|
||||
hasConfigOptions: !!payload["configoptions"],
|
||||
hasCustomFields: !!payload["customfields"],
|
||||
pids: payload.pid,
|
||||
billingCycles: payload.billingcycle,
|
||||
hasConfigOptions: !!payload.configoptions,
|
||||
hasCustomFields: !!payload.customfields,
|
||||
});
|
||||
|
||||
return payload as Record<string, unknown>;
|
||||
return payload;
|
||||
}
|
||||
|
||||
private toWhmcsOrderResult(response: WhmcsAddOrderResponse): WhmcsOrderResult {
|
||||
|
||||
@ -15,6 +15,8 @@ import { TokenRevocationService } from "./infra/token/token-revocation.service.j
|
||||
import { PasswordResetTokenService } from "./infra/token/password-reset-token.service.js";
|
||||
import { CacheModule } from "@bff/infra/cache/cache.module.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 { PasswordWorkflowService } from "./infra/workflows/password-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,
|
||||
TokenStorageService,
|
||||
TokenRevocationService,
|
||||
TokenGeneratorService,
|
||||
TokenRefreshService,
|
||||
AuthTokenService,
|
||||
JoseJwtService,
|
||||
PasswordResetTokenService,
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
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 { TokenBlacklistService } from "./token-blacklist.service.js";
|
||||
export { TokenStorageService } from "./token-storage.service.js";
|
||||
|
||||
214
apps/bff/src/modules/auth/infra/token/token-generator.service.ts
Normal file
214
apps/bff/src/modules/auth/infra/token/token-generator.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Redis } from "ioredis";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
@ -53,7 +53,7 @@ export class TokenMigrationService {
|
||||
this.logger.log("Starting token migration", { dryRun });
|
||||
|
||||
if (this.redis.status !== "ready") {
|
||||
throw new Error("Redis is not ready for migration");
|
||||
throw new ServiceUnavailableException("Redis is not ready for migration");
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
336
apps/bff/src/modules/auth/infra/token/token-refresh.service.ts
Normal file
336
apps/bff/src/modules/auth/infra/token/token-refresh.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -1,26 +1,14 @@
|
||||
import {
|
||||
Injectable,
|
||||
Inject,
|
||||
UnauthorizedException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
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 { UserAuth, UserRole } from "@customer-portal/domain/customer";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||
import { JoseJwtService } from "./jose-jwt.service.js";
|
||||
import { TokenStorageService } from "./token-storage.service.js";
|
||||
import { TokenGeneratorService } from "./token-generator.service.js";
|
||||
import { TokenRefreshService } from "./token-refresh.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 {
|
||||
userId: string;
|
||||
/**
|
||||
@ -38,76 +26,46 @@ export interface RefreshTokenPayload extends JWTPayload {
|
||||
type: "refresh";
|
||||
}
|
||||
|
||||
interface DeviceInfo {
|
||||
export interface DeviceInfo {
|
||||
deviceId?: string | undefined;
|
||||
userAgent?: string | undefined;
|
||||
}
|
||||
|
||||
interface ValidatedTokenContext {
|
||||
payload: RefreshTokenPayload;
|
||||
familyId: string;
|
||||
refreshTokenHash: string;
|
||||
remainingSeconds: number;
|
||||
absoluteExpiresAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailable";
|
||||
|
||||
/**
|
||||
* Auth Token Service
|
||||
*
|
||||
* Handles token generation and refresh operations.
|
||||
* Delegates storage operations to TokenStorageService.
|
||||
* Delegates revocation operations to TokenRevocationService.
|
||||
* Thin orchestrator that delegates to focused services:
|
||||
* - TokenGeneratorService: token creation
|
||||
* - TokenRefreshService: refresh + rotation logic
|
||||
* - TokenRevocationService: token revocation
|
||||
*
|
||||
* Preserves the existing public API so consumers don't need changes.
|
||||
*/
|
||||
@Injectable()
|
||||
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 maintenanceMode: boolean;
|
||||
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(
|
||||
jwtService: JoseJwtService,
|
||||
private readonly generator: TokenGeneratorService,
|
||||
private readonly refreshService: TokenRefreshService,
|
||||
private readonly revocation: TokenRevocationService,
|
||||
configService: ConfigService,
|
||||
storage: TokenStorageService,
|
||||
revocation: TokenRevocationService,
|
||||
@Inject("REDIS_CLIENT") redis: Redis,
|
||||
@Inject(Logger) logger: Logger,
|
||||
usersService: UsersService
|
||||
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {
|
||||
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.configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true";
|
||||
this.maintenanceMode = this.configService.get("AUTH_MAINTENANCE_MODE", "false") === "true";
|
||||
this.maintenanceMessage = this.configService.get(
|
||||
configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true";
|
||||
this.maintenanceMode = configService.get("AUTH_MAINTENANCE_MODE", "false") === "true";
|
||||
this.maintenanceMessage = configService.get(
|
||||
"AUTH_MAINTENANCE_MESSAGE",
|
||||
"Authentication service is temporarily unavailable for maintenance. Please try again later."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authentication service is available
|
||||
*/
|
||||
private checkServiceAvailability(): void {
|
||||
if (this.maintenanceMode) {
|
||||
this.logger.warn("Authentication service in maintenance mode", {
|
||||
@ -129,113 +87,11 @@ export class AuthTokenService {
|
||||
* Generate a new token pair with refresh token rotation
|
||||
*/
|
||||
async generateTokenPair(
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role?: UserRole;
|
||||
},
|
||||
user: { id: string; email: string; role?: UserRole },
|
||||
deviceInfo?: DeviceInfo
|
||||
): 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();
|
||||
|
||||
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",
|
||||
};
|
||||
return this.generator.generateTokenPair(user, deviceInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -245,323 +101,8 @@ export class AuthTokenService {
|
||||
refreshToken: string,
|
||||
deviceInfo?: DeviceInfo
|
||||
): Promise<{ tokens: AuthTokens; user: UserAuth }> {
|
||||
if (!refreshToken) {
|
||||
throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
this.checkServiceAvailability();
|
||||
this.checkRedisForRefresh();
|
||||
|
||||
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);
|
||||
return this.refreshService.refreshTokens(refreshToken, deviceInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -591,34 +132,4 @@ export class AuthTokenService {
|
||||
async revokeAllUserTokens(userId: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
@ -88,7 +89,7 @@ export class PasswordWorkflowService {
|
||||
}
|
||||
const prismaUser = await this.usersService.findByIdInternal(user.id);
|
||||
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);
|
||||
|
||||
@ -106,7 +107,7 @@ export class PasswordWorkflowService {
|
||||
criticality: OperationCriticality.CRITICAL,
|
||||
context: `Set password for user ${user.id}`,
|
||||
logger: this.logger,
|
||||
rethrow: [NotFoundException, BadRequestException],
|
||||
rethrow: [NotFoundException, BadRequestException, InternalServerErrorException],
|
||||
fallbackMessage: "Failed to set password",
|
||||
}
|
||||
);
|
||||
@ -163,7 +164,7 @@ export class PasswordWorkflowService {
|
||||
await this.usersService.update(prismaUser.id, { passwordHash });
|
||||
const freshUser = await this.usersService.findByIdInternal(prismaUser.id);
|
||||
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
|
||||
await this.tokenService.revokeAllUserTokens(freshUser.id);
|
||||
@ -174,7 +175,7 @@ export class PasswordWorkflowService {
|
||||
criticality: OperationCriticality.CRITICAL,
|
||||
context: "Reset password",
|
||||
logger: this.logger,
|
||||
rethrow: [BadRequestException],
|
||||
rethrow: [BadRequestException, InternalServerErrorException],
|
||||
fallbackMessage: "Failed to reset password",
|
||||
}
|
||||
);
|
||||
@ -221,7 +222,7 @@ export class PasswordWorkflowService {
|
||||
await this.usersService.update(user.id, { passwordHash });
|
||||
const prismaUser = await this.usersService.findByIdInternal(user.id);
|
||||
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);
|
||||
|
||||
@ -251,7 +252,7 @@ export class PasswordWorkflowService {
|
||||
criticality: OperationCriticality.CRITICAL,
|
||||
context: `Change password for user ${user.id}`,
|
||||
logger: this.logger,
|
||||
rethrow: [NotFoundException, BadRequestException],
|
||||
rethrow: [NotFoundException, BadRequestException, InternalServerErrorException],
|
||||
fallbackMessage: "Failed to change password",
|
||||
}
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
||||
@ -36,7 +36,7 @@ export class GenerateAuthResultStep {
|
||||
// Load fresh user from DB
|
||||
const freshUser = await this.usersService.findByIdInternal(userId);
|
||||
if (!freshUser) {
|
||||
throw new Error("Failed to load created user");
|
||||
throw new InternalServerErrorException("Failed to load created user");
|
||||
}
|
||||
|
||||
// Log audit event
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
@ -110,7 +111,7 @@ export class WhmcsLinkWorkflowService {
|
||||
|
||||
const prismaUser = await this.usersService.findByIdInternal(createdUser.id);
|
||||
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);
|
||||
@ -137,7 +138,12 @@ export class WhmcsLinkWorkflowService {
|
||||
criticality: OperationCriticality.CRITICAL,
|
||||
context: "WHMCS account linking",
|
||||
logger: this.logger,
|
||||
rethrow: [BadRequestException, ConflictException, UnauthorizedException],
|
||||
rethrow: [
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
],
|
||||
fallbackMessage: "Failed to link WHMCS account",
|
||||
}
|
||||
);
|
||||
|
||||
@ -23,6 +23,12 @@ import { GetStartedSessionService } from "../otp/get-started-session.service.js"
|
||||
import { SignupUserCreationService } from "./signup/signup-user-creation.service.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
|
||||
*
|
||||
@ -226,15 +232,20 @@ export class WhmcsMigrationWorkflowService {
|
||||
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
|
||||
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
const updateData: WhmcsMigrationClientUpdate = {
|
||||
password2: password,
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@ -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 { Logger } from "nestjs-pino";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
|
||||
import type { Request } from "express";
|
||||
@ -30,13 +31,12 @@ type RequestWithRoute = RequestWithCookies & {
|
||||
|
||||
@Injectable()
|
||||
export class GlobalAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(GlobalAuthGuard.name);
|
||||
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private readonly tokenBlacklistService: TokenBlacklistService,
|
||||
private readonly jwtService: JoseJwtService,
|
||||
private readonly usersService: UsersService
|
||||
private readonly usersService: UsersService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
|
||||
@ -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 { Logger } from "nestjs-pino";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
@ -24,9 +25,10 @@ type RequestWithUser = Request & { user?: UserWithRole };
|
||||
*/
|
||||
@Injectable()
|
||||
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 {
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<Permission[] | undefined>(
|
||||
|
||||
@ -6,7 +6,12 @@
|
||||
* 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 { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||
import type { Notification as PrismaNotification } from "@prisma/client";
|
||||
@ -57,7 +62,7 @@ export class NotificationService {
|
||||
async createNotification(params: CreateNotificationParams): Promise<Notification> {
|
||||
const template = NOTIFICATION_TEMPLATES[params.type];
|
||||
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)
|
||||
@ -116,7 +121,7 @@ export class NotificationService {
|
||||
userId: params.userId,
|
||||
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),
|
||||
userId,
|
||||
});
|
||||
throw new Error("Failed to get notifications");
|
||||
throw new InternalServerErrorException("Failed to get notifications");
|
||||
}
|
||||
}
|
||||
|
||||
@ -221,7 +226,7 @@ export class NotificationService {
|
||||
notificationId,
|
||||
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),
|
||||
userId,
|
||||
});
|
||||
throw new Error("Failed to update notifications");
|
||||
throw new InternalServerErrorException("Failed to update notifications");
|
||||
}
|
||||
}
|
||||
|
||||
@ -268,7 +273,7 @@ export class NotificationService {
|
||||
notificationId,
|
||||
userId,
|
||||
});
|
||||
throw new Error("Failed to dismiss notification");
|
||||
throw new InternalServerErrorException("Failed to dismiss notification");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -63,17 +63,7 @@ export class CheckoutController {
|
||||
req.user?.id
|
||||
);
|
||||
|
||||
const session = await this.checkoutSessions.createSession(body, cart);
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
expiresAt: session.expiresAt,
|
||||
orderType: body.orderType,
|
||||
cart: {
|
||||
items: cart.items,
|
||||
totals: cart.totals,
|
||||
},
|
||||
};
|
||||
return this.checkoutSessions.createSessionWithResponse(body, cart);
|
||||
}
|
||||
|
||||
@Get("session/:sessionId")
|
||||
@ -84,16 +74,7 @@ export class CheckoutController {
|
||||
type: CheckoutSessionResponseDto,
|
||||
})
|
||||
async getSession(@Param() params: CheckoutSessionIdParamDto) {
|
||||
const session = await this.checkoutSessions.getSession(params.sessionId);
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
expiresAt: session.expiresAt,
|
||||
orderType: session.request.orderType,
|
||||
cart: {
|
||||
items: session.cart.items,
|
||||
totals: session.cart.totals,
|
||||
},
|
||||
};
|
||||
return this.checkoutSessions.getSessionResponse(params.sessionId);
|
||||
}
|
||||
|
||||
@Post("validate")
|
||||
|
||||
@ -5,6 +5,7 @@ import { CacheService } from "@bff/infra/cache/cache.service.js";
|
||||
import type {
|
||||
CheckoutBuildCartRequest,
|
||||
CheckoutCart,
|
||||
CheckoutSessionResponse,
|
||||
CreateOrderRequest,
|
||||
OrderCreateResponse,
|
||||
} from "@customer-portal/domain/orders";
|
||||
@ -62,6 +63,43 @@ export class CheckoutSessionService {
|
||||
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> {
|
||||
const key = this.buildKey(sessionId);
|
||||
await this.cache.del(key);
|
||||
|
||||
@ -3,6 +3,45 @@ import { Logger } from "nestjs-pino";
|
||||
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
|
||||
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
|
||||
*
|
||||
@ -22,8 +61,8 @@ export class FulfillmentContextMapper {
|
||||
extractConfigurations(
|
||||
rawConfigurations: unknown,
|
||||
sfOrder?: SalesforceOrderRecord | null
|
||||
): Record<string, unknown> {
|
||||
const config: Record<string, unknown> = {};
|
||||
): FulfillmentConfigurations {
|
||||
const config: FulfillmentConfigurations = {};
|
||||
|
||||
// Start with payload configurations if provided
|
||||
if (rawConfigurations && typeof rawConfigurations === "object") {
|
||||
@ -49,7 +88,7 @@ export class FulfillmentContextMapper {
|
||||
}
|
||||
// MNP fields
|
||||
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) {
|
||||
config["mnpNumber"] = sfOrder.MNP_Reservation_Number__c;
|
||||
|
||||
@ -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 { SimFulfillmentService, type SimFulfillmentResult } from "./sim-fulfillment.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 { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { createOrderNotes, mapOrderToWhmcsItems } from "@customer-portal/domain/orders/providers";
|
||||
@ -68,7 +71,7 @@ export class FulfillmentStepExecutors {
|
||||
*/
|
||||
async executeSimFulfillment(
|
||||
ctx: OrderFulfillmentContext,
|
||||
payload: Record<string, unknown>
|
||||
payload: FulfillmentPayload
|
||||
): Promise<SimFulfillmentResult> {
|
||||
if (ctx.orderDetails?.orderType !== "SIM") {
|
||||
return { activated: false, simType: "eSIM" as const };
|
||||
@ -183,7 +186,7 @@ export class FulfillmentStepExecutors {
|
||||
simFulfillmentResult?: SimFulfillmentResult
|
||||
): WhmcsOrderItemMappingResult {
|
||||
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!
|
||||
|
||||
@ -9,6 +9,8 @@ import type {
|
||||
} from "./order-fulfillment-orchestrator.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
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>;
|
||||
|
||||
@ -45,10 +47,7 @@ export class FulfillmentStepFactory {
|
||||
* 8. sf_registration_complete
|
||||
* 9. opportunity_update
|
||||
*/
|
||||
buildSteps(
|
||||
context: OrderFulfillmentContext,
|
||||
payload: Record<string, unknown>
|
||||
): DistributedStep[] {
|
||||
buildSteps(context: OrderFulfillmentContext, payload: FulfillmentPayload): DistributedStep[] {
|
||||
// Mutable state container for cross-step data
|
||||
const state: StepState = {};
|
||||
|
||||
@ -106,7 +105,7 @@ export class FulfillmentStepFactory {
|
||||
|
||||
private createSimFulfillmentStep(
|
||||
ctx: OrderFulfillmentContext,
|
||||
payload: Record<string, unknown>,
|
||||
payload: FulfillmentPayload,
|
||||
state: StepState
|
||||
): DistributedStep {
|
||||
return {
|
||||
@ -163,7 +162,7 @@ export class FulfillmentStepFactory {
|
||||
description: "Create order in WHMCS",
|
||||
execute: this.createTrackedStep(ctx, "whmcs_create", async () => {
|
||||
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);
|
||||
// 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",
|
||||
execute: this.createTrackedStep(ctx, "whmcs_accept", async () => {
|
||||
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);
|
||||
// Update state with serviceIds from accept (services are created on accept, not on add)
|
||||
|
||||
@ -4,7 +4,15 @@ import type { OrderBusinessValidation, UserMapping } from "@customer-portal/doma
|
||||
import { UsersService } from "@bff/modules/users/application/users.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) {
|
||||
target[key] = value;
|
||||
}
|
||||
@ -26,11 +34,11 @@ export class OrderBuilder {
|
||||
userMapping: UserMapping,
|
||||
pricebookId: string,
|
||||
userId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<SalesforceOrderFields> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const orderFieldNames = this.orderFieldMap.fields.order;
|
||||
|
||||
const orderFields: Record<string, unknown> = {
|
||||
const orderFields: SalesforceOrderFields = {
|
||||
AccountId: userMapping.sfAccountId,
|
||||
EffectiveDate: today,
|
||||
Status: "Pending Review",
|
||||
@ -59,7 +67,7 @@ export class OrderBuilder {
|
||||
}
|
||||
|
||||
private addActivationFields(
|
||||
orderFields: Record<string, unknown>,
|
||||
orderFields: SalesforceOrderFields,
|
||||
body: OrderBusinessValidation,
|
||||
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
|
||||
): void {
|
||||
@ -71,7 +79,7 @@ export class OrderBuilder {
|
||||
}
|
||||
|
||||
private addInternetFields(
|
||||
orderFields: Record<string, unknown>,
|
||||
orderFields: SalesforceOrderFields,
|
||||
body: OrderBusinessValidation,
|
||||
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
|
||||
): void {
|
||||
@ -80,7 +88,7 @@ export class OrderBuilder {
|
||||
}
|
||||
|
||||
private addSimFields(
|
||||
orderFields: Record<string, unknown>,
|
||||
orderFields: SalesforceOrderFields,
|
||||
body: OrderBusinessValidation,
|
||||
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
|
||||
): void {
|
||||
@ -116,7 +124,7 @@ export class OrderBuilder {
|
||||
}
|
||||
|
||||
private async addAddressSnapshot(
|
||||
orderFields: Record<string, unknown>,
|
||||
orderFields: SalesforceOrderFields,
|
||||
userId: string,
|
||||
body: OrderBusinessValidation,
|
||||
fieldNames: SalesforceOrderFieldMapService["fields"]["order"]
|
||||
|
||||
@ -8,6 +8,7 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service.
|
||||
import { FulfillmentStepFactory } from "./fulfillment-step-factory.service.js";
|
||||
import { FulfillmentSideEffectsService } from "./fulfillment-side-effects.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 { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import type { OrderDetails } from "@customer-portal/domain/orders";
|
||||
@ -65,7 +66,7 @@ export class OrderFulfillmentOrchestrator {
|
||||
*/
|
||||
async executeFulfillment(
|
||||
sfOrderId: string,
|
||||
payload: Record<string, unknown>,
|
||||
payload: FulfillmentPayload,
|
||||
idempotencyKey: string
|
||||
): Promise<OrderFulfillmentContext> {
|
||||
const context = this.initializeContext(sfOrderId, idempotencyKey, payload);
|
||||
@ -198,7 +199,7 @@ export class OrderFulfillmentOrchestrator {
|
||||
private initializeContext(
|
||||
sfOrderId: string,
|
||||
idempotencyKey: string,
|
||||
payload: Record<string, unknown>
|
||||
payload: FulfillmentPayload
|
||||
): OrderFulfillmentContext {
|
||||
const orderType = typeof payload["orderType"] === "string" ? payload["orderType"] : "Unknown";
|
||||
return {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { CacheService } from "@bff/infra/cache/cache.service.js";
|
||||
import type { OrderCreateResponse } from "@customer-portal/domain/orders";
|
||||
@ -23,13 +24,17 @@ import type { OrderCreateResponse } from "@customer-portal/domain/orders";
|
||||
export class OrderIdempotencyService {
|
||||
private readonly RESULT_PREFIX = "order-result:";
|
||||
private readonly LOCK_PREFIX = "order-lock:";
|
||||
private readonly RESULT_TTL_SECONDS = 86400; // 24 hours
|
||||
private readonly LOCK_TTL_SECONDS = 60; // 60 seconds for processing
|
||||
private readonly RESULT_TTL_SECONDS: number;
|
||||
private readonly LOCK_TTL_SECONDS: number;
|
||||
|
||||
constructor(
|
||||
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
|
||||
|
||||
@ -2,6 +2,7 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nes
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { OrderPricebookService } from "./order-pricebook.service.js";
|
||||
import { createOrderRequestSchema } from "@customer-portal/domain/orders";
|
||||
import { OrderValidationException } from "@bff/core/exceptions/domain-exceptions.js";
|
||||
|
||||
/**
|
||||
* Handles building order items from SKU data
|
||||
@ -51,7 +52,9 @@ export class OrderItemBuilder {
|
||||
{ sku: normalizedSkuValue, pbeId: meta.pricebookEntryId },
|
||||
"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({
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
SimActivationException,
|
||||
OrderValidationException,
|
||||
} 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
|
||||
@ -36,7 +37,7 @@ export interface SimAssignmentDetails {
|
||||
|
||||
export interface SimFulfillmentRequest {
|
||||
orderDetails: OrderDetails;
|
||||
configurations: Record<string, unknown>;
|
||||
configurations: FulfillmentConfigurations;
|
||||
/** Salesforce ID of the assigned Physical SIM (from Assign_Physical_SIM__c) */
|
||||
assignedPhysicalSimId?: string;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
private extractMnpConfig(config: Record<string, unknown>) {
|
||||
private extractMnpConfig(config: FulfillmentConfigurations) {
|
||||
const nested = config["mnp"];
|
||||
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"]);
|
||||
if (isMnpFlag && isMnpFlag.toLowerCase() !== "true") {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
/**
|
||||
* Simple per-instance SSE connection limiter.
|
||||
@ -11,9 +12,13 @@ import { Injectable } from "@nestjs/common";
|
||||
*/
|
||||
@Injectable()
|
||||
export class RealtimeConnectionLimiterService {
|
||||
private readonly maxPerUser = 3;
|
||||
private readonly maxPerUser: 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 {
|
||||
const current = this.counts.get(userId) ?? 0;
|
||||
if (current >= this.maxPerUser) {
|
||||
|
||||
@ -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 { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||
@ -88,7 +93,7 @@ export class InternetEligibilityService {
|
||||
): Promise<string> {
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
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");
|
||||
@ -151,7 +156,9 @@ export class InternetEligibilityService {
|
||||
sfAccountId,
|
||||
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;
|
||||
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> = {
|
||||
|
||||
@ -18,6 +18,7 @@ import { SalesforceCaseService } from "@bff/integrations/salesforce/services/sal
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { SalesforceOperationException } from "@bff/core/exceptions/domain-exceptions.js";
|
||||
import type {
|
||||
OrderPlacedCaseParams,
|
||||
EligibilityCheckCaseParams,
|
||||
@ -296,7 +297,7 @@ export class WorkflowCaseManager {
|
||||
filename,
|
||||
error: extractErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to create verification case");
|
||||
throw new SalesforceOperationException("Failed to create verification case");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import type {
|
||||
CancellationPreview,
|
||||
CancellationStatus,
|
||||
@ -28,15 +29,14 @@ function isValidPortalStage(stage: string): stage is PortalStage {
|
||||
|
||||
@Injectable()
|
||||
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
|
||||
constructor(
|
||||
private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator,
|
||||
private readonly opportunityService: SalesforceOpportunityService,
|
||||
private readonly internetCancellation: InternetCancellationService,
|
||||
private readonly simCancellation: SimCancellationService,
|
||||
private readonly validationCoordinator: SubscriptionValidationCoordinator
|
||||
private readonly validationCoordinator: SubscriptionValidationCoordinator,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 {
|
||||
action: string;
|
||||
@ -10,3 +20,19 @@ export interface SimActionNotification {
|
||||
export interface SimValidationResult {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { SimValidationService } from "../sim-validation.service.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 { SimBillingService } from "../queries/sim-billing.service.js";
|
||||
import { SimNotificationService } from "../support/sim-notification.service.js";
|
||||
@ -272,7 +273,7 @@ export class SimTopUpService {
|
||||
// to ensure consistency across all failure scenarios.
|
||||
// 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.`
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { SimCancellationService } from "./mutations/sim-cancellation.service.js"
|
||||
import { EsimManagementService } from "./mutations/esim-management.service.js";
|
||||
import { SimValidationService } from "./sim-validation.service.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 type {
|
||||
SimInfo,
|
||||
@ -182,10 +183,7 @@ export class SimOrchestrator {
|
||||
/**
|
||||
* Debug method to check subscription data for SIM services
|
||||
*/
|
||||
async debugSimSubscription(
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<Record<string, unknown>> {
|
||||
async debugSimSubscription(userId: string, subscriptionId: number): Promise<SimDebugInfo> {
|
||||
return this.simValidation.debugSimSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SubscriptionsOrchestrator } from "../../subscriptions-orchestrator.service.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 {
|
||||
cleanSimAccount,
|
||||
extractSimAccountFromSubscription,
|
||||
@ -83,10 +83,7 @@ export class SimValidationService {
|
||||
/**
|
||||
* Debug method to check subscription data for SIM services
|
||||
*/
|
||||
async debugSimSubscription(
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<Record<string, unknown>> {
|
||||
async debugSimSubscription(userId: string, subscriptionId: number): Promise<SimDebugInfo> {
|
||||
try {
|
||||
const subscription = await this.subscriptionsService.getSubscriptionById(
|
||||
userId,
|
||||
|
||||
@ -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.";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
url: string;
|
||||
senddata?: Record<string, unknown> | string;
|
||||
json?: Record<string, unknown> | string;
|
||||
result: Record<string, unknown> | string;
|
||||
senddata?: JsonObject | string;
|
||||
json?: JsonObject | 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.
|
||||
* Handles all SIM-related email notifications including:
|
||||
@ -326,8 +331,8 @@ Comments: ${params.comments || "N/A"}`;
|
||||
/**
|
||||
* Redact sensitive information from notification context
|
||||
*/
|
||||
private redactSensitiveFields(context: Record<string, unknown>): Record<string, unknown> {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
private redactSensitiveFields(context: SimNotificationContext): SimNotificationContext {
|
||||
const sanitized: SimNotificationContext = {};
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
if (typeof key === "string" && key.toLowerCase().includes("password")) {
|
||||
sanitized[key] = "[REDACTED]";
|
||||
|
||||
@ -15,6 +15,7 @@ import { SimPlanService } from "./services/mutations/sim-plan.service.js";
|
||||
import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js";
|
||||
import { EsimManagementService } from "./services/mutations/esim-management.service.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 { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
@ -126,7 +127,7 @@ export class SimController {
|
||||
async debugSimSubscription(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param() params: SubscriptionIdParamDto
|
||||
): Promise<Record<string, unknown>> {
|
||||
): Promise<SimDebugInfo> {
|
||||
return this.simOrchestrator.debugSimSubscription(req.user.id, params.id);
|
||||
}
|
||||
|
||||
|
||||
@ -140,7 +140,7 @@ function buildInvoiceActivities(invoices: RecentInvoice[]): Activity[] {
|
||||
const activities: Activity[] = [];
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const baseMetadata: Record<string, unknown> = {
|
||||
const baseMetadata: Record<string, string | number | boolean | null> = {
|
||||
amount: invoice.total,
|
||||
currency: invoice.currency ?? DEFAULT_CURRENCY,
|
||||
};
|
||||
@ -179,7 +179,7 @@ function buildInvoiceActivities(invoices: RecentInvoice[]): Activity[] {
|
||||
|
||||
function buildSubscriptionActivities(subscriptions: RecentSubscription[]): Activity[] {
|
||||
return subscriptions.map(subscription => {
|
||||
const metadata: Record<string, unknown> = {
|
||||
const metadata: Record<string, string | number | boolean | null> = {
|
||||
productName: subscription.productName,
|
||||
status: subscription.status,
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
|
||||
import { InjectQueue } from "@nestjs/bullmq";
|
||||
import { Queue } from "bullmq";
|
||||
import { Logger } from "nestjs-pino";
|
||||
@ -100,7 +100,9 @@ export class AddressReconcileQueueService {
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
throw new Error(`Failed to queue address reconciliation: ${errorMessage}`);
|
||||
throw new InternalServerErrorException(
|
||||
`Failed to queue address reconciliation: ${errorMessage}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,8 @@ import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
||||
import { ResidenceCardService } from "./residence-card.service.js";
|
||||
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"]);
|
||||
|
||||
type UploadedResidenceCard = {
|
||||
|
||||
@ -160,7 +160,7 @@ const whmcsInvoiceCommonSchema = z
|
||||
companyname: s.optional(),
|
||||
currencycode: s.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
.strip();
|
||||
|
||||
export const whmcsInvoiceListItemSchema = whmcsInvoiceCommonSchema.extend({
|
||||
id: numberLike,
|
||||
@ -290,6 +290,8 @@ export type WhmcsCurrency = z.infer<typeof whmcsCurrencySchema>;
|
||||
export const whmcsCurrenciesResponseSchema = z
|
||||
.object({
|
||||
result: z.enum(["success", "error"]).optional(),
|
||||
message: z.string().optional(),
|
||||
errorcode: z.string().optional(),
|
||||
totalresults: z
|
||||
.string()
|
||||
.transform(val => Number.parseInt(val, 10))
|
||||
@ -300,8 +302,7 @@ export const whmcsCurrenciesResponseSchema = z
|
||||
currency: z.array(whmcsCurrencySchema).or(whmcsCurrencySchema),
|
||||
})
|
||||
.optional(),
|
||||
// Allow any additional flat currency keys for flat format
|
||||
})
|
||||
.catchall(z.string().or(z.number()));
|
||||
.strip();
|
||||
|
||||
export type WhmcsCurrenciesResponse = z.infer<typeof whmcsCurrenciesResponseSchema>;
|
||||
|
||||
@ -41,7 +41,10 @@ export const cartItemSchema = z.object({
|
||||
planSku: z.string().min(1, "Plan SKU is required"),
|
||||
planName: z.string().min(1, "Plan name is required"),
|
||||
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({
|
||||
monthlyTotal: z.number().nonnegative(),
|
||||
oneTimeTotal: z.number().nonnegative(),
|
||||
|
||||
@ -675,6 +675,7 @@ export const apiErrorSchema = z.object({
|
||||
error: z.object({
|
||||
code: 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(),
|
||||
}),
|
||||
});
|
||||
|
||||
@ -13,6 +13,8 @@ import { z } from "zod";
|
||||
/**
|
||||
* 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({
|
||||
totalSize: z.number(),
|
||||
done: z.boolean(),
|
||||
|
||||
@ -26,3 +26,31 @@ export const whmcsNumberLike = z.union([z.number(), z.string()]);
|
||||
* Use for WHMCS boolean flags that arrive in varying formats.
|
||||
*/
|
||||
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());
|
||||
|
||||
@ -46,8 +46,7 @@ export const statusEnum = z.enum(["active", "inactive", "pending", "suspended"])
|
||||
export const priorityEnum = z.enum(["low", "medium", "high", "urgent"]);
|
||||
export const categoryEnum = z.enum(["technical", "billing", "account", "general"]);
|
||||
|
||||
export const billingCycleEnum = z.enum(["Monthly", "Quarterly", "Annually", "Onetime", "Free"]);
|
||||
export const subscriptionBillingCycleEnum = z.enum([
|
||||
export const billingCycleSchema = z.enum([
|
||||
"Monthly",
|
||||
"Quarterly",
|
||||
"Semi-Annually",
|
||||
@ -57,6 +56,7 @@ export const subscriptionBillingCycleEnum = z.enum([
|
||||
"One-time",
|
||||
"Free",
|
||||
]);
|
||||
export type BillingCycle = z.infer<typeof billingCycleSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Salesforce and SOQL Validation Schemas
|
||||
@ -123,6 +123,7 @@ export const apiErrorResponseSchema = z.object({
|
||||
error: z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
// Intentionally z.unknown() — error details vary by error type
|
||||
details: z.unknown().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
@ -56,7 +56,7 @@ function normalizeAddress(client: WhmcsRawClient): Address | undefined {
|
||||
country: client.country ?? null,
|
||||
countryCode: client.countrycode ?? 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 !== "");
|
||||
|
||||
@ -67,7 +67,7 @@ export const whmcsCustomFieldSchema = z
|
||||
name: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
.strip();
|
||||
|
||||
export const whmcsUserSchema = z
|
||||
.object({
|
||||
@ -76,7 +76,7 @@ export const whmcsUserSchema = z
|
||||
email: z.string(),
|
||||
is_owner: booleanLike.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
.strip();
|
||||
|
||||
export const whmcsEmailPreferencesSchema = z
|
||||
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
||||
@ -89,13 +89,13 @@ const customFieldsSchema = z
|
||||
.object({
|
||||
customfield: z.union([whmcsCustomFieldSchema, z.array(whmcsCustomFieldSchema)]),
|
||||
})
|
||||
.passthrough(),
|
||||
.strip(),
|
||||
])
|
||||
.optional();
|
||||
|
||||
const usersSchema = z
|
||||
.object({ user: z.union([whmcsUserSchema, z.array(whmcsUserSchema)]) })
|
||||
.passthrough()
|
||||
.strip()
|
||||
.optional();
|
||||
|
||||
export const whmcsClientSchema = z
|
||||
@ -142,7 +142,7 @@ export const whmcsClientSchema = z
|
||||
customfields: customFieldsSchema,
|
||||
users: usersSchema,
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
.strip();
|
||||
|
||||
export const whmcsClientStatsSchema = z
|
||||
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
||||
@ -155,7 +155,7 @@ export const whmcsClientResponseSchema = z
|
||||
client: whmcsClientSchema,
|
||||
stats: whmcsClientStatsSchema,
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
.strip();
|
||||
|
||||
export type WhmcsCustomField = z.infer<typeof whmcsCustomFieldSchema>;
|
||||
export type WhmcsUser = z.infer<typeof whmcsUserSchema>;
|
||||
|
||||
@ -13,6 +13,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { countryCodeSchema } from "../common/schema.js";
|
||||
import {
|
||||
whmcsNumberLike as numberLike,
|
||||
whmcsBooleanLike as booleanLike,
|
||||
} from "../common/providers/whmcs-utils/index.js";
|
||||
import {
|
||||
whmcsClientSchema as whmcsRawClientSchema,
|
||||
whmcsCustomFieldSchema,
|
||||
@ -23,8 +27,6 @@ import {
|
||||
// ============================================================================
|
||||
|
||||
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
|
||||
@ -172,30 +174,33 @@ const subUserSchema = z
|
||||
*/
|
||||
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
|
||||
.union([
|
||||
z.record(z.string(), z.string()),
|
||||
whmcsRawCustomFieldsArraySchema,
|
||||
z
|
||||
.object({
|
||||
customfield: z.union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema]).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
])
|
||||
.preprocess(normalizeCustomFields, z.array(whmcsCustomFieldSchema).optional())
|
||||
.optional();
|
||||
|
||||
const whmcsUsersSchema = z
|
||||
.union([
|
||||
z.array(subUserSchema),
|
||||
z
|
||||
.object({
|
||||
user: z.union([subUserSchema, z.array(subUserSchema)]).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
])
|
||||
.optional();
|
||||
const normalizeUsers = (input: unknown): unknown => {
|
||||
if (!input) return input;
|
||||
if (Array.isArray(input)) return input;
|
||||
if (typeof input === "object" && input !== null && "user" in input) {
|
||||
const u = (input as Record<string, unknown>)["user"];
|
||||
if (Array.isArray(u)) return u;
|
||||
return u ? [u] : input;
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
const whmcsUsersSchema = z.preprocess(normalizeUsers, z.array(subUserSchema).optional()).optional();
|
||||
|
||||
/**
|
||||
* WhmcsClient - Full WHMCS client data
|
||||
|
||||
@ -18,7 +18,10 @@ export const activitySchema = z.object({
|
||||
description: z.string().optional(),
|
||||
date: z.string(),
|
||||
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
|
||||
@ -68,7 +71,10 @@ export const dashboardSummarySchema = z.object({
|
||||
export const dashboardErrorSchema = z.object({
|
||||
code: 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"]);
|
||||
|
||||
@ -18,8 +18,32 @@ export const salesforceOrderItemRecordSchema = z.object({
|
||||
UnitPrice: z.number().nullable().optional(),
|
||||
TotalPrice: z.number().nullable().optional(),
|
||||
PricebookEntryId: z.string().nullable().optional(),
|
||||
// Note: PricebookEntry nested object comes from catalog domain
|
||||
PricebookEntry: z.unknown().nullable().optional(),
|
||||
// Minimal PricebookEntry shape for fields used by the order mapper.
|
||||
// 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(),
|
||||
WHMCS_Service_ID__c: z.string().nullable().optional(),
|
||||
CreatedDate: z.string().optional(),
|
||||
@ -114,7 +138,7 @@ export const salesforceOrderProvisionEventPayloadSchema = z
|
||||
OrderId__c: z.string().optional(),
|
||||
OrderId: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
.strip();
|
||||
|
||||
export type SalesforceOrderProvisionEventPayload = z.infer<
|
||||
typeof salesforceOrderProvisionEventPayloadSchema
|
||||
@ -128,7 +152,7 @@ export const salesforceOrderProvisionEventSchema = z
|
||||
payload: salesforceOrderProvisionEventPayloadSchema,
|
||||
replayId: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
.strip();
|
||||
|
||||
export type SalesforceOrderProvisionEvent = z.infer<typeof salesforceOrderProvisionEventSchema>;
|
||||
|
||||
@ -152,7 +176,7 @@ export const salesforcePubSubErrorMetadataSchema = z
|
||||
.object({
|
||||
"error-code": z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
.strip();
|
||||
|
||||
export type SalesforcePubSubErrorMetadata = z.infer<typeof salesforcePubSubErrorMetadataSchema>;
|
||||
|
||||
@ -164,7 +188,7 @@ export const salesforcePubSubErrorSchema = z
|
||||
details: z.string().optional(),
|
||||
metadata: salesforcePubSubErrorMetadataSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
.strip();
|
||||
|
||||
export type SalesforcePubSubError = z.infer<typeof salesforcePubSubErrorSchema>;
|
||||
|
||||
@ -203,6 +227,7 @@ export const salesforcePubSubCallbackSchema = z.object({
|
||||
data: z.union([
|
||||
salesforceOrderProvisionEventSchema,
|
||||
salesforcePubSubErrorSchema,
|
||||
// Fallback for unknown Pub/Sub event types whose shape cannot be predicted
|
||||
z.record(z.string(), z.unknown()),
|
||||
z.null(),
|
||||
]),
|
||||
|
||||
@ -187,7 +187,7 @@ export const orderSelectionsSchema = z
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
.strip();
|
||||
|
||||
export type OrderSelections = z.infer<typeof orderSelectionsSchema>;
|
||||
|
||||
|
||||
@ -48,7 +48,9 @@ export const whmcsPaymentGatewayRawSchema = z.object({
|
||||
display_name: z.string().optional(),
|
||||
type: z.string(),
|
||||
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>;
|
||||
|
||||
@ -47,7 +47,10 @@ export const paymentGatewaySchema = z.object({
|
||||
displayName: z.string(),
|
||||
type: paymentGatewayTypeSchema,
|
||||
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({
|
||||
|
||||
@ -43,7 +43,7 @@ export interface SalesforceProductFieldMap {
|
||||
export interface PricingTier {
|
||||
name: string;
|
||||
price: number;
|
||||
billingCycle: "Monthly" | "Onetime" | "Annual";
|
||||
billingCycle: "Monthly" | "One-time" | "Annually";
|
||||
description?: string;
|
||||
features?: string[];
|
||||
isRecommended?: boolean;
|
||||
|
||||
@ -11,27 +11,10 @@ import { z } from "zod";
|
||||
import {
|
||||
whmcsString as s,
|
||||
whmcsNumberLike as numberLike,
|
||||
whmcsRequiredNumber as normalizeRequiredNumber,
|
||||
whmcsOptionalNumber as normalizeOptionalNumber,
|
||||
} 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 = () =>
|
||||
z
|
||||
.union([z.string(), z.number()])
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { billingCycleSchema } from "../common/schema.js";
|
||||
|
||||
// Subscription Status Schema
|
||||
export const subscriptionStatusSchema = z.enum([
|
||||
"Active",
|
||||
@ -17,17 +19,8 @@ export const subscriptionStatusSchema = z.enum([
|
||||
"Completed",
|
||||
]);
|
||||
|
||||
// Subscription Cycle Schema
|
||||
export const subscriptionCycleSchema = z.enum([
|
||||
"Monthly",
|
||||
"Quarterly",
|
||||
"Semi-Annually",
|
||||
"Annually",
|
||||
"Biennially",
|
||||
"Triennially",
|
||||
"One-time",
|
||||
"Free",
|
||||
]);
|
||||
// Subscription Cycle Schema — re-exported from common
|
||||
export const subscriptionCycleSchema = billingCycleSchema;
|
||||
|
||||
// Subscription Schema
|
||||
export const subscriptionSchema = z.object({
|
||||
@ -105,7 +98,8 @@ export const subscriptionStatsSchema = z.object({
|
||||
*/
|
||||
export const simActionResponseSchema = z.object({
|
||||
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(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user