Refactor EditorConfig and improve code formatting across multiple files

- Replaced the existing .editorconfig file to establish consistent coding styles across editors.
- Cleaned up whitespace and improved formatting in various TypeScript files for better readability.
- Enhanced logging and error handling in Salesforce and WHMCS services to provide clearer insights during operations.
- Streamlined order processing and caching mechanisms, ensuring efficient handling of API requests and responses.
- Updated test cases to reflect changes in service methods and improve overall test coverage.
This commit is contained in:
barsa 2025-11-17 11:49:58 +09:00
parent b5533994c2
commit ff55639b2d
54 changed files with 645 additions and 811 deletions

View File

@ -1 +0,0 @@
config/.editorconfig

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# EditorConfig helps maintain consistent coding styles across editors
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

View File

@ -85,7 +85,7 @@ export const envSchema = z.object({
SF_PUBSUB_NUM_REQUESTED: z.string().default("25"),
SF_PUBSUB_QUEUE_MAX: z.string().default("100"),
SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"),
// CDC-specific channels (using /data/ prefix for Change Data Capture)
SF_CATALOG_PRODUCT_CDC_CHANNEL: z.string().default("/data/Product2ChangeEvent"),
SF_CATALOG_PRICEBOOKENTRY_CDC_CHANNEL: z.string().default("/data/PricebookEntryChangeEvent"),

View File

@ -740,7 +740,8 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
getDegradationState(): SalesforceDegradationSnapshot {
this.clearDegradeWindowIfElapsed();
const usagePercent = this.dailyApiLimit > 0 ? this.metrics.dailyApiUsage / this.dailyApiLimit : 0;
const usagePercent =
this.dailyApiLimit > 0 ? this.metrics.dailyApiUsage / this.dailyApiLimit : 0;
return {
degraded: this.degradeState.until !== null,
reason: this.degradeState.reason,

View File

@ -47,11 +47,7 @@ describe("CsrfController", () => {
controller.getCsrfToken(req, res);
expect(csrfService.generateToken).toHaveBeenCalledWith(
undefined,
"session-456",
"user-123"
);
expect(csrfService.generateToken).toHaveBeenCalledWith(undefined, "session-456", "user-123");
});
it("defaults to anonymous user during refresh and still forwards identifiers correctly", () => {

View File

@ -171,10 +171,13 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const invalidated = await this.catalogCache.invalidateProducts(productIds);
if (!invalidated) {
this.logger.debug("No catalog cache entries were linked to product IDs; falling back to full invalidation", {
channel,
productIds,
});
this.logger.debug(
"No catalog cache entries were linked to product IDs; falling back to full invalidation",
{
channel,
productIds,
}
);
await this.invalidateAllCatalogs();
}
}
@ -206,16 +209,17 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
productId,
});
const invalidated = await this.catalogCache.invalidateProducts(
productId ? [productId] : []
);
const invalidated = await this.catalogCache.invalidateProducts(productId ? [productId] : []);
if (!invalidated) {
this.logger.debug("No catalog cache entries mapped to product from pricebook event; performing full invalidation", {
channel,
pricebookId,
productId,
});
this.logger.debug(
"No catalog cache entries mapped to product from pricebook event; performing full invalidation",
{
channel,
pricebookId,
productId,
}
);
await this.invalidateAllCatalogs();
}
}

View File

@ -9,8 +9,8 @@ import { OrderCdcSubscriber } from "./order-cdc.subscriber";
@Module({
imports: [ConfigModule, IntegrationsModule, OrdersModule, CatalogModule],
providers: [
CatalogCdcSubscriber, // CDC for catalog cache invalidation
OrderCdcSubscriber, // CDC for order cache invalidation
CatalogCdcSubscriber, // CDC for catalog cache invalidation
OrderCdcSubscriber, // CDC for order cache invalidation
],
})
export class SalesforceEventsModule {}

View File

@ -68,15 +68,10 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
]);
// Internal OrderItem fields - ignore these
private readonly INTERNAL_ORDER_ITEM_FIELDS = new Set([
"WHMCS_Service_ID__c",
]);
private readonly INTERNAL_ORDER_ITEM_FIELDS = new Set(["WHMCS_Service_ID__c"]);
// Statuses that trigger provisioning
private readonly PROVISION_TRIGGER_STATUSES = new Set([
"Approved",
"Reactivate",
]);
private readonly PROVISION_TRIGGER_STATUSES = new Set(["Approved", "Reactivate"]);
constructor(
private readonly config: ConfigService,
@ -96,11 +91,9 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
}
const orderChannel =
this.config.get<string>("SF_ORDER_CDC_CHANNEL")?.trim() ||
"/data/OrderChangeEvent";
this.config.get<string>("SF_ORDER_CDC_CHANNEL")?.trim() || "/data/OrderChangeEvent";
const orderItemChannel =
this.config.get<string>("SF_ORDER_ITEM_CDC_CHANNEL")?.trim() ||
"/data/OrderItemChangeEvent";
this.config.get<string>("SF_ORDER_ITEM_CDC_CHANNEL")?.trim() || "/data/OrderItemChangeEvent";
this.logger.log("Initializing Salesforce Order CDC subscriber", {
orderChannel,
@ -148,7 +141,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return this.client;
}
const ctor = await this.loadPubSubCtor();
const ctor = this.loadPubSubCtor();
await this.sfConnection.connect();
const accessToken = this.sfConnection.getAccessToken();
@ -202,7 +195,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
}
}
private async loadPubSubCtor(): Promise<PubSubCtor> {
private loadPubSubCtor(): PubSubCtor {
if (this.pubSubCtor) {
return this.pubSubCtor;
}
@ -284,11 +277,14 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const hasCustomerFacingChange = this.hasCustomerFacingChanges(changedFields);
if (!hasCustomerFacingChange) {
this.logger.debug("Order CDC event contains only internal field changes; skipping cache invalidation", {
channel,
orderId,
changedFields: Array.from(changedFields),
});
this.logger.debug(
"Order CDC event contains only internal field changes; skipping cache invalidation",
{
channel,
orderId,
changedFields: Array.from(changedFields),
}
);
return;
}
@ -336,11 +332,14 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
}
if (status && !this.PROVISION_TRIGGER_STATUSES.has(status)) {
this.logger.debug("Activation status set to Activating but order status is not a provisioning trigger", {
orderId,
activationStatus,
status,
});
this.logger.debug(
"Activation status set to Activating but order status is not a provisioning trigger",
{
orderId,
activationStatus,
status,
}
);
return;
}
@ -441,7 +440,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
// Remove internal fields from changed fields
const customerFacingChanges = Array.from(changedFields).filter(
(field) => !this.INTERNAL_FIELDS.has(field)
field => !this.INTERNAL_FIELDS.has(field)
);
return customerFacingChanges.length > 0;
@ -456,7 +455,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
}
const customerFacingChanges = Array.from(changedFields).filter(
(field) => !this.INTERNAL_ORDER_ITEM_FIELDS.has(field)
field => !this.INTERNAL_ORDER_ITEM_FIELDS.has(field)
);
return customerFacingChanges.length > 0;
@ -471,15 +470,16 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const header = this.extractChangeEventHeader(payload);
const headerChangedFields = Array.isArray(header?.changedFields)
? (header?.changedFields as unknown[])
.filter((field): field is string => typeof field === "string" && field.length > 0)
? (header?.changedFields as unknown[]).filter(
(field): field is string => typeof field === "string" && field.length > 0
)
: [];
// CDC provides changed fields in different formats depending on API version
// Try to extract from common locations
const changedFieldsArray =
(payload.changedFields as string[] | undefined) ||
((payload.changeOrigin as { changedFields?: string[] })?.changedFields) ||
(payload.changeOrigin as { changedFields?: string[] })?.changedFields ||
[];
return new Set([
@ -523,14 +523,14 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return undefined;
}
private extractChangeEventHeader(
payload: Record<string, unknown>
): {
changedFields?: unknown;
recordIds?: unknown;
entityName?: unknown;
changeType?: unknown;
} | undefined {
private extractChangeEventHeader(payload: Record<string, unknown>):
| {
changedFields?: unknown;
recordIds?: unknown;
entityName?: unknown;
changeType?: unknown;
}
| undefined {
const header = payload["ChangeEventHeader"];
if (header && typeof header === "object") {
return header as {

View File

@ -37,4 +37,3 @@ export class SalesforceReadThrottleGuard implements CanActivate {
);
}
}

View File

@ -37,4 +37,3 @@ export class SalesforceWriteThrottleGuard implements CanActivate {
);
}
}

View File

@ -56,9 +56,7 @@ export class SalesforceAccountService {
}
}
async findWithDetailsByCustomerNumber(
customerNumber: string
): Promise<{
async findWithDetailsByCustomerNumber(customerNumber: string): Promise<{
id: string;
Name?: string | null;
WH_Account__c?: string | null;
@ -160,7 +158,7 @@ export class SalesforceAccountService {
this.logger.warn("Salesforce update method not available");
return;
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
this.logger.debug("Updated Salesforce account portal fields", {
accountId: validAccountId,

View File

@ -9,10 +9,24 @@ describe("SalesforceConnection", () => {
get: jest.fn(),
} as unknown as ConfigService;
const requestQueue: Partial<SalesforceRequestQueueService> = {
execute: jest.fn().mockImplementation(async (fn) => fn()),
executeHighPriority: jest.fn().mockImplementation(async (fn) => fn()),
};
const execute = jest.fn<
Promise<unknown>,
[fn: () => Promise<unknown>, options?: Record<string, unknown>]
>(async (fn): Promise<unknown> => {
return await fn();
});
const executeHighPriority = jest.fn<
Promise<unknown>,
[fn: () => Promise<unknown>, options?: Record<string, unknown>]
>(async (fn): Promise<unknown> => {
return await fn();
});
const requestQueue = {
execute,
executeHighPriority,
} as unknown as SalesforceRequestQueueService;
const logger = {
debug: jest.fn(),
@ -21,7 +35,7 @@ describe("SalesforceConnection", () => {
log: jest.fn(),
} as unknown as Logger;
const service = new SalesforceConnection(configService, requestQueue as SalesforceRequestQueueService, logger);
const service = new SalesforceConnection(configService, requestQueue, logger);
// Override internal connection with simple stubs
const queryMock = jest.fn().mockResolvedValue("query-result");
@ -31,14 +45,18 @@ describe("SalesforceConnection", () => {
create: jest.fn().mockResolvedValue({ id: "001" }),
update: jest.fn().mockResolvedValue({}),
}),
} as unknown as typeof service["connection"];
} as unknown as (typeof service)["connection"];
jest.spyOn(service as unknown as { ensureConnected(): Promise<void> }, "ensureConnected").mockResolvedValue();
jest
.spyOn(service as unknown as { ensureConnected(): Promise<void> }, "ensureConnected")
.mockResolvedValue();
return {
service,
requestQueue,
queryMock,
execute,
executeHighPriority,
};
};
@ -47,12 +65,12 @@ describe("SalesforceConnection", () => {
});
it("routes standard queries through the request queue with derived priority metadata", async () => {
const { service, requestQueue, queryMock } = createService();
const { service, execute, queryMock } = createService();
await service.query("SELECT Id FROM Account WHERE Id = '001'");
expect(requestQueue.execute).toHaveBeenCalledTimes(1);
const [, options] = (requestQueue.execute as jest.Mock).mock.calls[0];
expect(execute).toHaveBeenCalledTimes(1);
const [, options] = execute.mock.calls[0];
expect(options).toMatchObject({
priority: 8,
isLongRunning: false,
@ -62,13 +80,13 @@ describe("SalesforceConnection", () => {
});
it("routes SObject create operations through the high-priority queue", async () => {
const { service, requestQueue } = createService();
const { service, executeHighPriority } = createService();
const sobject = service.sobject("Order");
await sobject.create({ Name: "Test" });
expect(requestQueue.executeHighPriority).toHaveBeenCalledTimes(1);
const [, options] = (requestQueue.executeHighPriority as jest.Mock).mock.calls[0];
expect(executeHighPriority).toHaveBeenCalledTimes(1);
const [, options] = executeHighPriority.mock.calls[0];
expect(options).toMatchObject({ label: "salesforce:sobject:Order:create" });
});
});

View File

@ -246,49 +246,47 @@ export class SalesforceConnection {
const isLongRunning = this.isLongRunningQuery(soql);
const label = options.label ?? this.deriveQueryLabel(soql);
try {
return await this.requestQueue.execute(
async () => {
await this.ensureConnected();
try {
return await this.connection.query(soql);
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn("Salesforce session expired, attempting to re-authenticate", {
originalError: getErrorMessage(error),
tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null,
tokenExpiresAt: this.tokenExpiresAt ? new Date(this.tokenExpiresAt).toISOString() : null,
return this.requestQueue.execute(
async () => {
await this.ensureConnected();
try {
return await this.connection.query(soql);
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn("Salesforce session expired, attempting to re-authenticate", {
originalError: getErrorMessage(error),
tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null,
tokenExpiresAt: this.tokenExpiresAt
? new Date(this.tokenExpiresAt).toISOString()
: null,
});
try {
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying query after re-authentication", {
reAuthDuration,
});
try {
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying query after re-authentication", {
reAuthDuration,
});
return await this.connection.query(soql);
} catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.error("Failed to re-authenticate or retry query", {
originalError: getErrorMessage(error),
retryError: getErrorMessage(retryError),
reAuthDuration,
});
throw retryError;
}
return await this.connection.query(soql);
} catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.error("Failed to re-authenticate or retry query", {
originalError: getErrorMessage(error),
retryError: getErrorMessage(retryError),
reAuthDuration,
});
throw retryError;
}
throw error;
}
},
{ priority, isLongRunning, label }
);
} catch (error: unknown) {
throw error;
}
throw error;
}
},
{ priority, isLongRunning, label }
);
}
private isSessionExpiredError(error: unknown): boolean {
@ -314,90 +312,96 @@ export class SalesforceConnection {
// Return a wrapper that handles session expiration for SObject operations
return {
create: async (data: object) => {
return this.requestQueue.executeHighPriority(async () => {
try {
await this.ensureConnected();
return await originalSObject.create(data);
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn(
"Salesforce session expired during SObject create, attempting to re-authenticate",
{
sobjectType: type,
originalError: getErrorMessage(error),
return this.requestQueue.executeHighPriority(
async () => {
try {
await this.ensureConnected();
return await originalSObject.create(data);
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn(
"Salesforce session expired during SObject create, attempting to re-authenticate",
{
sobjectType: type,
originalError: getErrorMessage(error),
}
);
try {
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying SObject create after re-authentication", {
sobjectType: type,
reAuthDuration,
});
const newSObject = this.connection.sobject(type);
return await newSObject.create(data);
} catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.error("Failed to re-authenticate or retry SObject create", {
sobjectType: type,
originalError: getErrorMessage(error),
retryError: getErrorMessage(retryError),
reAuthDuration,
});
throw retryError;
}
);
try {
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying SObject create after re-authentication", {
sobjectType: type,
reAuthDuration,
});
const newSObject = this.connection.sobject(type);
return await newSObject.create(data);
} catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.error("Failed to re-authenticate or retry SObject create", {
sobjectType: type,
originalError: getErrorMessage(error),
retryError: getErrorMessage(retryError),
reAuthDuration,
});
throw retryError;
}
throw error;
}
throw error;
}
}, { label: `salesforce:sobject:${type}:create` });
},
{ label: `salesforce:sobject:${type}:create` }
);
},
update: async (data: object & { Id: string }) => {
return this.requestQueue.executeHighPriority(async () => {
try {
await this.ensureConnected();
return await originalSObject.update(data);
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn(
"Salesforce session expired during SObject update, attempting to re-authenticate",
{
sobjectType: type,
recordId: data.Id,
originalError: getErrorMessage(error),
return this.requestQueue.executeHighPriority(
async () => {
try {
await this.ensureConnected();
return await originalSObject.update(data);
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn(
"Salesforce session expired during SObject update, attempting to re-authenticate",
{
sobjectType: type,
recordId: data.Id,
originalError: getErrorMessage(error),
}
);
try {
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying SObject update after re-authentication", {
sobjectType: type,
recordId: data.Id,
reAuthDuration,
});
const newSObject = this.connection.sobject(type);
return await newSObject.update(data);
} catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.error("Failed to re-authenticate or retry SObject update", {
sobjectType: type,
recordId: data.Id,
originalError: getErrorMessage(error),
retryError: getErrorMessage(retryError),
reAuthDuration,
});
throw retryError;
}
);
try {
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying SObject update after re-authentication", {
sobjectType: type,
recordId: data.Id,
reAuthDuration,
});
const newSObject = this.connection.sobject(type);
return await newSObject.update(data);
} catch (retryError) {
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.error("Failed to re-authenticate or retry SObject update", {
sobjectType: type,
recordId: data.Id,
originalError: getErrorMessage(error),
retryError: getErrorMessage(retryError),
reAuthDuration,
});
throw retryError;
}
throw error;
}
throw error;
}
}, { label: `salesforce:sobject:${type}:update` });
},
{ label: `salesforce:sobject:${type}:update` }
);
},
};
}
@ -412,39 +416,42 @@ export class SalesforceConnection {
const path = this.buildCompositeTreePath(sobjectType, allOrNone);
const label = options.label ?? `salesforce:composite:${sobjectType}`;
return this.requestQueue.execute(async () => {
await this.ensureConnected();
return this.requestQueue.execute(
async () => {
await this.ensureConnected();
if (!body || typeof body !== "object") {
throw new TypeError("Salesforce composite tree body must be an object");
}
const payload = body as Record<string, unknown> | Record<string, unknown>[];
try {
return (await this.connection.requestPost(path, payload)) as T;
} catch (error) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn("Salesforce session expired during composite tree request, retrying", {
sobjectType,
originalError: getErrorMessage(error),
});
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying composite tree request after re-authentication", {
sobjectType,
reAuthDuration,
});
return (await this.connection.requestPost(path, payload)) as T;
if (!body || typeof body !== "object") {
throw new TypeError("Salesforce composite tree body must be an object");
}
throw error;
}
}, { priority, label });
const payload = body as Record<string, unknown> | Record<string, unknown>[];
try {
return (await this.connection.requestPost(path, payload)) as T;
} catch (error) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn("Salesforce session expired during composite tree request, retrying", {
sobjectType,
originalError: getErrorMessage(error),
});
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying composite tree request after re-authentication", {
sobjectType,
reAuthDuration,
});
return (await this.connection.requestPost(path, payload)) as T;
}
throw error;
}
},
{ priority, label }
);
}
private buildCompositeTreePath(sobjectType: string, allOrNone: boolean): string {
@ -513,31 +520,34 @@ export class SalesforceConnection {
*/
async queryHighPriority(soql: string, options: { label?: string } = {}): Promise<unknown> {
const label = options.label ?? `${this.deriveQueryLabel(soql)}:high`;
return this.requestQueue.executeHighPriority(async () => {
await this.ensureConnected();
try {
return await this.connection.query(soql);
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn(
"Salesforce session expired during high-priority query, attempting to re-authenticate",
{
originalError: getErrorMessage(error),
}
);
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying high-priority query after re-authentication", {
reAuthDuration,
});
return this.requestQueue.executeHighPriority(
async () => {
await this.ensureConnected();
try {
return await this.connection.query(soql);
} catch (error: unknown) {
if (this.isSessionExpiredError(error)) {
const reAuthStartTime = Date.now();
this.logger.warn(
"Salesforce session expired during high-priority query, attempting to re-authenticate",
{
originalError: getErrorMessage(error),
}
);
await this.connect(true);
const reAuthDuration = Date.now() - reAuthStartTime;
this.logger.debug("Retrying high-priority query after re-authentication", {
reAuthDuration,
});
return await this.connection.query(soql);
}
throw error;
}
throw error;
}
}, { label });
},
{ label }
);
}
/**

View File

@ -51,15 +51,13 @@ export class SalesforceOrderService {
// Build queries
const orderQueryFields = this.orderFieldMap.buildOrderSelectFields(["Account.Name"]).join(", ");
const orderItemProduct2Fields = this.orderFieldMap.buildOrderItemProduct2Fields().map(
f => `PricebookEntry.Product2.${f}`
);
const orderItemProduct2Fields = this.orderFieldMap
.buildOrderItemProduct2Fields()
.map(f => `PricebookEntry.Product2.${f}`);
const orderItemSelect = [
...this.orderFieldMap.buildOrderItemSelectFields(),
...orderItemProduct2Fields,
].join(
", "
);
].join(", ");
const orderSoql = `
SELECT ${orderQueryFields}
@ -222,15 +220,13 @@ export class SalesforceOrderService {
// Build queries
const orderQueryFields = this.orderFieldMap.buildOrderSelectFields().join(", ");
const orderItemProduct2Fields = this.orderFieldMap.buildOrderItemProduct2Fields().map(
f => `PricebookEntry.Product2.${f}`
);
const orderItemProduct2Fields = this.orderFieldMap
.buildOrderItemProduct2Fields()
.map(f => `PricebookEntry.Product2.${f}`);
const orderItemSelect = [
...this.orderFieldMap.buildOrderItemSelectFields(),
...orderItemProduct2Fields,
].join(
", "
);
].join(", ");
const ordersSoql = `
SELECT ${orderQueryFields}

View File

@ -133,7 +133,7 @@ export class WhmcsHttpClientService {
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const requestBody = this.buildRequestBody(config, action, params, options);
const requestBody = this.buildRequestBody(config, action, params);
const url = `${config.baseUrl}/includes/api.php`;
const response = await fetch(url, {
@ -170,8 +170,7 @@ export class WhmcsHttpClientService {
private buildRequestBody(
config: WhmcsApiConfig,
action: string,
params: Record<string, unknown>,
options: WhmcsRequestOptions
params: Record<string, unknown>
): string {
const formData = new URLSearchParams();
@ -221,11 +220,7 @@ export class WhmcsHttpClientService {
if (typeof value === "string") {
return value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (value instanceof Date) {
@ -273,14 +268,9 @@ export class WhmcsHttpClientService {
// Handle error responses according to WHMCS API documentation
if (parsedResponse.result === "error") {
const errorMessage = this.toDisplayString(
parsedResponse.message ?? parsedResponse.error,
"Unknown WHMCS API error"
);
const errorCode = this.toDisplayString(parsedResponse.errorcode, "unknown");
// Extract all additional fields from the response for debugging
const { result, message, error, errorcode, ...additionalFields } = parsedResponse;
const { message, error, errorcode, ...additionalFields } = parsedResponse;
const errorMessage = this.toDisplayString(message ?? error, "Unknown WHMCS API error");
const errorCode = this.toDisplayString(errorcode, "unknown");
this.logger.error(`WHMCS API returned error [${action}]`, {
errorMessage,
@ -291,10 +281,11 @@ export class WhmcsHttpClientService {
});
// Include full context in the error for better debugging
const errorContext = Object.keys(additionalFields).length > 0
? ` | Additional details: ${JSON.stringify(additionalFields)}`
: '';
const errorContext =
Object.keys(additionalFields).length > 0
? ` | Additional details: ${JSON.stringify(additionalFields)}`
: "";
throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})${errorContext}`);
}

View File

@ -28,7 +28,7 @@ export class WhmcsOrderService {
/**
* Create order in WHMCS using AddOrder API
* Maps Salesforce OrderItems to WHMCS products
*
*
* WHMCS API Response Structure:
* Success: { orderid, productids, serviceids, addonids, domainids, invoiceid }
* Error: Thrown by HTTP client before returning
@ -49,7 +49,7 @@ export class WhmcsOrderService {
clientId: params.clientId,
productCount: Array.isArray(addOrderPayload.pid) ? addOrderPayload.pid.length : 0,
pids: addOrderPayload.pid,
quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added
quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added
billingCycles: addOrderPayload.billingcycle,
hasConfigOptions: Boolean(addOrderPayload.configoptions),
hasCustomFields: Boolean(addOrderPayload.customfields),
@ -60,9 +60,7 @@ export class WhmcsOrderService {
// Call WHMCS AddOrder 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.addOrder(
addOrderPayload
)) as WhmcsAddOrderResponse;
const response = (await this.connection.addOrder(addOrderPayload)) as WhmcsAddOrderResponse;
// Log the full response for debugging
this.logger.debug("WHMCS AddOrder response", {
@ -115,7 +113,7 @@ export class WhmcsOrderService {
/**
* Accept/provision order in WHMCS using AcceptOrder API
* This activates services and creates subscriptions
*
*
* WHMCS API Response Structure:
* Success: { orderid, invoiceid, serviceids, addonids, domainids }
* Error: Thrown by HTTP client before returning

View File

@ -1,8 +1,6 @@
export const PORTAL_STATUS_ACTIVE = "Active" as const;
export const PORTAL_STATUS_NOT_YET = "Not Yet" as const;
export type PortalStatus =
| typeof PORTAL_STATUS_ACTIVE
| typeof PORTAL_STATUS_NOT_YET;
export type PortalStatus = typeof PORTAL_STATUS_ACTIVE | typeof PORTAL_STATUS_NOT_YET;
export const PORTAL_SOURCE_NEW_SIGNUP = "New Signup" as const;
export const PORTAL_SOURCE_MIGRATED = "Migrated" as const;

View File

@ -506,7 +506,8 @@ export class SignupWorkflowService {
return unwrapped.value;
}
const resolved = await this.salesforceService.findAccountWithDetailsByCustomerNumber(normalized);
const resolved =
await this.salesforceService.findAccountWithDetailsByCustomerNumber(normalized);
await this.cache.set(
cacheKey,
this.wrapAccountCacheEntry(resolved ?? null),
@ -528,9 +529,10 @@ export class SignupWorkflowService {
return `${this.accountCachePrefix}${customerNumber}`;
}
private unwrapAccountCacheEntry(
cached: SignupAccountCacheEntry | null
): { hit: boolean; value: SignupAccountSnapshot | null } {
private unwrapAccountCacheEntry(cached: SignupAccountCacheEntry | null): {
hit: boolean;
value: SignupAccountSnapshot | null;
} {
if (!cached) {
return { hit: false, value: null };
}
@ -542,9 +544,7 @@ export class SignupWorkflowService {
return { hit: true, value: (cached as unknown as SignupAccountSnapshot) ?? null };
}
private wrapAccountCacheEntry(
snapshot: SignupAccountSnapshot | null
): SignupAccountCacheEntry {
private wrapAccountCacheEntry(snapshot: SignupAccountSnapshot | null): SignupAccountCacheEntry {
return {
value: snapshot ?? null,
__signupCache: true,

View File

@ -107,7 +107,10 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => {
@Controller("auth")
export class AuthController {
constructor(private authFacade: AuthFacade, private readonly jwtService: JwtService) {}
constructor(
private authFacade: AuthFacade,
private readonly jwtService: JwtService
) {}
private setAuthCookies(res: Response, tokens: AuthTokens): void {
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
@ -211,7 +214,7 @@ export class AuthController {
if (payload?.sub) {
userId = payload.sub;
}
} catch (error) {
} catch {
// Ignore verification errors we still want to clear client cookies.
}
}

View File

@ -27,10 +27,7 @@ export class FailedLoginThrottleGuard {
}
}
static applyRateLimitHeaders(
request: RequestWithRateLimit,
response: Response
): void {
static applyRateLimitHeaders(request: RequestWithRateLimit, response: Response): void {
const outcome = request.__authRateLimit;
if (!outcome) return;

View File

@ -16,7 +16,8 @@ import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator";
import { getErrorMessage } from "@bff/core/utils/error.util";
type CookieValue = string | undefined;
type RequestWithCookies = Omit<Request, "cookies"> & {
type RequestBase = Omit<Request, "cookies" | "route">;
type RequestWithCookies = RequestBase & {
cookies?: Record<string, CookieValue>;
};
type RequestWithRoute = RequestWithCookies & {

View File

@ -23,11 +23,6 @@ import { CatalogCacheService } from "./services/catalog-cache.service";
VpnCatalogService,
CatalogCacheService,
],
exports: [
InternetCatalogService,
SimCatalogService,
VpnCatalogService,
CatalogCacheService,
],
exports: [InternetCatalogService, SimCatalogService, VpnCatalogService, CatalogCacheService],
})
export class CatalogModule {}

View File

@ -41,7 +41,7 @@ export class BaseCatalogService {
): Promise<TRecord[]> {
try {
const res = (await this.sf.query(soql, {
label: `catalog:${context.replace(/\s+/g, "_" ).toLowerCase()}`,
label: `catalog:${context.replace(/\s+/g, "_").toLowerCase()}`,
})) as SalesforceResponse<TRecord>;
return res.records ?? [];
} catch (error: unknown) {

View File

@ -35,10 +35,10 @@ export class CatalogCacheService {
// Hybrid approach: CDC for real-time invalidation + TTL for backup cleanup
// Primary: CDC events invalidate cache when data changes (real-time)
// Backup: TTL expires unused cache entries (memory management)
private readonly CATALOG_TTL: number | null = null; // CDC-driven invalidation
private readonly STATIC_TTL: number | null = null; // CDC-driven invalidation
private readonly ELIGIBILITY_TTL: number | null = null; // CDC-driven invalidation
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
private readonly CATALOG_TTL: number | null = null; // CDC-driven invalidation
private readonly STATIC_TTL: number | null = null; // CDC-driven invalidation
private readonly ELIGIBILITY_TTL: number | null = null; // CDC-driven invalidation
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
private readonly metrics: CatalogCacheSnapshot = {
catalog: { hits: 0, misses: 0 },
@ -197,7 +197,7 @@ export class CatalogCacheService {
// 3. No cache hit and no in-flight request - fetch fresh data
this.metrics[bucket].misses++;
const fetchPromise = (async () => {
try {
const fresh = await fetchFn();
@ -209,22 +209,18 @@ export class CatalogCacheService {
if (unwrapped.dependencies) {
await this.unlinkDependenciesForKey(key, unwrapped.dependencies);
}
// Store in Redis for future requests
if (ttlSeconds === null) {
await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies));
} else {
await this.cache.set(
key,
this.wrapCachedValue(valueToStore, dependencies),
ttlSeconds
);
await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies), ttlSeconds);
}
if (dependencies) {
await this.linkDependencies(key, dependencies);
}
return fresh;
} finally {
// Clean up: Remove from in-flight map when done (success or failure)
@ -372,5 +368,7 @@ export class CatalogCacheService {
export interface CatalogCacheOptions<T> {
allowNull?: boolean;
resolveDependencies?: (value: T) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
resolveDependencies?: (
value: T
) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
}

View File

@ -101,9 +101,7 @@ export class InternetCatalogService extends BaseCatalogService {
},
{
resolveDependencies: installations => ({
productIds: installations
.map(item => item.id)
.filter((id): id is string => Boolean(id)),
productIds: installations.map(item => item.id).filter((id): id is string => Boolean(id)),
}),
}
);

View File

@ -80,9 +80,7 @@ export class SimCatalogService extends BaseCatalogService {
},
{
resolveDependencies: products => ({
productIds: products
.map(product => product.id)
.filter((id): id is string => Boolean(id)),
productIds: products.map(product => product.id).filter((id): id is string => Boolean(id)),
}),
}
);
@ -119,9 +117,7 @@ export class SimCatalogService extends BaseCatalogService {
},
{
resolveDependencies: products => ({
productIds: products
.map(product => product.id)
.filter((id): id is string => Boolean(id)),
productIds: products.map(product => product.id).filter((id): id is string => Boolean(id)),
}),
}
);

View File

@ -64,11 +64,6 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module";
ProvisioningQueueService,
ProvisioningProcessor,
],
exports: [
OrderOrchestrator,
CheckoutService,
ProvisioningQueueService,
OrdersCacheService,
],
exports: [OrderOrchestrator, CheckoutService, ProvisioningQueueService, OrdersCacheService],
})
export class OrdersModule {}

View File

@ -61,12 +61,7 @@ describe("CheckoutService - personalized carts", () => {
},
});
await service.buildCart(
ORDER_TYPE.INTERNET,
{ planSku: "PLAN-1" },
undefined,
"user-123"
);
await service.buildCart(ORDER_TYPE.INTERNET, { planSku: "PLAN-1" }, undefined, "user-123");
expect(internetCatalogService.getPlansForUser).toHaveBeenCalledWith("user-123");
expect(internetCatalogService.getPlans).not.toHaveBeenCalled();

View File

@ -96,7 +96,11 @@ export class OrderBuilder {
assignIfString(orderFields, fieldNames.mvnoAccountNumber, config.mvnoAccountNumber);
assignIfString(orderFields, fieldNames.portingLastName, config.portingLastName);
assignIfString(orderFields, fieldNames.portingFirstName, config.portingFirstName);
assignIfString(orderFields, fieldNames.portingLastNameKatakana, config.portingLastNameKatakana);
assignIfString(
orderFields,
fieldNames.portingLastNameKatakana,
config.portingLastNameKatakana
);
assignIfString(
orderFields,
fieldNames.portingFirstNameKatakana,

View File

@ -157,200 +157,203 @@ export class OrderFulfillmentOrchestrator {
const fulfillmentResult =
await this.distributedTransactionService.executeDistributedTransaction(
[
{
id: "sf_status_update",
description: "Update Salesforce order status to Activating",
execute: this.createTrackedStep(context, "sf_status_update", async () => {
const result = await this.salesforceService.updateOrder({
Id: sfOrderId,
Activation_Status__c: "Activating",
});
this.orderEvents.publish(sfOrderId, {
orderId: sfOrderId,
status: "Processing",
activationStatus: "Activating",
stage: "in_progress",
source: "fulfillment",
timestamp: new Date().toISOString(),
});
return result;
}),
rollback: async () => {
await this.salesforceService.updateOrder({
Id: sfOrderId,
Activation_Status__c: "Failed",
});
{
id: "sf_status_update",
description: "Update Salesforce order status to Activating",
execute: this.createTrackedStep(context, "sf_status_update", async () => {
const result = await this.salesforceService.updateOrder({
Id: sfOrderId,
Activation_Status__c: "Activating",
});
this.orderEvents.publish(sfOrderId, {
orderId: sfOrderId,
status: "Processing",
activationStatus: "Activating",
stage: "in_progress",
source: "fulfillment",
timestamp: new Date().toISOString(),
});
return result;
}),
rollback: async () => {
await this.salesforceService.updateOrder({
Id: sfOrderId,
Activation_Status__c: "Failed",
});
},
critical: true,
},
critical: true,
},
{
id: "order_details",
description: "Retain order details in context",
execute: this.createTrackedStep(context, "order_details", () =>
Promise.resolve(context.orderDetails)
),
critical: false,
},
{
id: "mapping",
description: "Map OrderItems to WHMCS format",
execute: this.createTrackedStep(context, "mapping", () => {
if (!context.orderDetails) {
return Promise.reject(new Error("Order details are required for mapping"));
}
// Use domain mapper directly - single transformation!
const result = OrderProviders.Whmcs.mapOrderToWhmcsItems(context.orderDetails);
mappingResult = result;
{
id: "order_details",
description: "Retain order details in context",
execute: this.createTrackedStep(context, "order_details", () =>
Promise.resolve(context.orderDetails)
),
critical: false,
},
{
id: "mapping",
description: "Map OrderItems to WHMCS format",
execute: this.createTrackedStep(context, "mapping", () => {
if (!context.orderDetails) {
return Promise.reject(new Error("Order details are required for mapping"));
}
// Use domain mapper directly - single transformation!
const result = OrderProviders.Whmcs.mapOrderToWhmcsItems(context.orderDetails);
mappingResult = result;
this.logger.log("OrderItems mapped to WHMCS", {
totalItems: result.summary.totalItems,
serviceItems: result.summary.serviceItems,
activationItems: result.summary.activationItems,
});
return Promise.resolve(result);
}),
critical: true,
},
{
id: "whmcs_create",
description: "Create order in WHMCS",
execute: this.createTrackedStep(context, "whmcs_create", async () => {
if (!context.validation) {
throw new OrderValidationException("Validation context is missing", {
sfOrderId,
step: "whmcs_create_order",
this.logger.log("OrderItems mapped to WHMCS", {
totalItems: result.summary.totalItems,
serviceItems: result.summary.serviceItems,
activationItems: result.summary.activationItems,
});
}
if (!mappingResult) {
throw new FulfillmentException("Mapping result is not available", {
sfOrderId,
step: "whmcs_create_order",
});
}
const orderNotes = OrderProviders.Whmcs.createOrderNotes(
sfOrderId,
`Provisioned from Salesforce Order ${sfOrderId}`
);
const result = await this.whmcsOrderService.addOrder({
clientId: context.validation.clientId,
items: mappingResult.whmcsItems,
paymentMethod: "stripe",
promoCode: "1st Month Free (Monthly Plan)",
sfOrderId,
notes: orderNotes,
noinvoiceemail: true,
noemail: true,
});
whmcsCreateResult = result;
return result;
}),
rollback: () => {
if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API
// Manual intervention required for order cleanup
this.logger.error(
"WHMCS order created but fulfillment failed - manual cleanup required",
{
orderId: whmcsCreateResult.orderId,
return Promise.resolve(result);
}),
critical: true,
},
{
id: "whmcs_create",
description: "Create order in WHMCS",
execute: this.createTrackedStep(context, "whmcs_create", async () => {
if (!context.validation) {
throw new OrderValidationException("Validation context is missing", {
sfOrderId,
action: "MANUAL_CLEANUP_REQUIRED",
}
);
}
return Promise.resolve();
},
critical: true,
},
{
id: "whmcs_accept",
description: "Accept/provision order in WHMCS",
execute: this.createTrackedStep(context, "whmcs_accept", async () => {
if (!whmcsCreateResult?.orderId) {
throw new WhmcsOperationException("WHMCS order ID missing before acceptance step", {
sfOrderId,
step: "whmcs_accept_order",
});
}
await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId);
return { orderId: whmcsCreateResult.orderId };
}),
rollback: () => {
if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API for accepted orders
// Manual intervention required for service termination
this.logger.error(
"WHMCS order accepted but fulfillment failed - manual cleanup required",
{
orderId: whmcsCreateResult.orderId,
serviceIds: whmcsCreateResult.serviceIds,
step: "whmcs_create_order",
});
}
if (!mappingResult) {
throw new FulfillmentException("Mapping result is not available", {
sfOrderId,
action: "MANUAL_SERVICE_TERMINATION_REQUIRED",
}
step: "whmcs_create_order",
});
}
const orderNotes = OrderProviders.Whmcs.createOrderNotes(
sfOrderId,
`Provisioned from Salesforce Order ${sfOrderId}`
);
}
return Promise.resolve();
},
critical: true,
},
{
id: "sim_fulfillment",
description: "SIM-specific fulfillment (if applicable)",
execute: this.createTrackedStep(context, "sim_fulfillment", async () => {
if (context.orderDetails?.orderType === "SIM") {
const configurations = this.extractConfigurations(payload.configurations);
await this.simFulfillmentService.fulfillSimOrder({
orderDetails: context.orderDetails,
configurations,
const result = await this.whmcsOrderService.addOrder({
clientId: context.validation.clientId,
items: mappingResult.whmcsItems,
paymentMethod: "stripe",
promoCode: "1st Month Free (Monthly Plan)",
sfOrderId,
notes: orderNotes,
noinvoiceemail: true,
noemail: true,
});
return { completed: true as const };
}
return { skipped: true as const };
}),
critical: false, // SIM fulfillment failure shouldn't rollback the entire order
},
{
id: "sf_success_update",
description: "Update Salesforce with success",
execute: this.createTrackedStep(context, "sf_success_update", async () => {
const result = await this.salesforceService.updateOrder({
Id: sfOrderId,
Status: "Completed",
Activation_Status__c: "Activated",
WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(),
});
this.orderEvents.publish(sfOrderId, {
orderId: sfOrderId,
status: "Completed",
activationStatus: "Activated",
stage: "completed",
source: "fulfillment",
timestamp: new Date().toISOString(),
payload: {
whmcsOrderId: whmcsCreateResult?.orderId,
whmcsServiceIds: whmcsCreateResult?.serviceIds,
},
});
return result;
}),
rollback: async () => {
await this.salesforceService.updateOrder({
Id: sfOrderId,
Activation_Status__c: "Failed",
});
whmcsCreateResult = result;
return result;
}),
rollback: () => {
if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API
// Manual intervention required for order cleanup
this.logger.error(
"WHMCS order created but fulfillment failed - manual cleanup required",
{
orderId: whmcsCreateResult.orderId,
sfOrderId,
action: "MANUAL_CLEANUP_REQUIRED",
}
);
}
return Promise.resolve();
},
critical: true,
},
critical: true,
},
],
{
description: `Order fulfillment for ${sfOrderId}`,
timeout: 300000, // 5 minutes
continueOnNonCriticalFailure: true,
}
{
id: "whmcs_accept",
description: "Accept/provision order in WHMCS",
execute: this.createTrackedStep(context, "whmcs_accept", async () => {
if (!whmcsCreateResult?.orderId) {
throw new WhmcsOperationException(
"WHMCS order ID missing before acceptance step",
{
sfOrderId,
step: "whmcs_accept_order",
}
);
}
await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId);
return { orderId: whmcsCreateResult.orderId };
}),
rollback: () => {
if (whmcsCreateResult?.orderId) {
// Note: WHMCS doesn't have an automated cancel API for accepted orders
// Manual intervention required for service termination
this.logger.error(
"WHMCS order accepted but fulfillment failed - manual cleanup required",
{
orderId: whmcsCreateResult.orderId,
serviceIds: whmcsCreateResult.serviceIds,
sfOrderId,
action: "MANUAL_SERVICE_TERMINATION_REQUIRED",
}
);
}
return Promise.resolve();
},
critical: true,
},
{
id: "sim_fulfillment",
description: "SIM-specific fulfillment (if applicable)",
execute: this.createTrackedStep(context, "sim_fulfillment", async () => {
if (context.orderDetails?.orderType === "SIM") {
const configurations = this.extractConfigurations(payload.configurations);
await this.simFulfillmentService.fulfillSimOrder({
orderDetails: context.orderDetails,
configurations,
});
return { completed: true as const };
}
return { skipped: true as const };
}),
critical: false, // SIM fulfillment failure shouldn't rollback the entire order
},
{
id: "sf_success_update",
description: "Update Salesforce with success",
execute: this.createTrackedStep(context, "sf_success_update", async () => {
const result = await this.salesforceService.updateOrder({
Id: sfOrderId,
Status: "Completed",
Activation_Status__c: "Activated",
WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(),
});
this.orderEvents.publish(sfOrderId, {
orderId: sfOrderId,
status: "Completed",
activationStatus: "Activated",
stage: "completed",
source: "fulfillment",
timestamp: new Date().toISOString(),
payload: {
whmcsOrderId: whmcsCreateResult?.orderId,
whmcsServiceIds: whmcsCreateResult?.serviceIds,
},
});
return result;
}),
rollback: async () => {
await this.salesforceService.updateOrder({
Id: sfOrderId,
Activation_Status__c: "Failed",
});
},
critical: true,
},
],
{
description: `Order fulfillment for ${sfOrderId}`,
timeout: 300000, // 5 minutes
continueOnNonCriticalFailure: true,
}
);
if (!fulfillmentResult.success) {

View File

@ -64,12 +64,12 @@ describe("OrderOrchestrator.getOrderForUser", () => {
sfAccountId: expectedOrder.accountId,
whmcsClientId: 42,
});
jest.spyOn(orchestrator, "getOrder").mockResolvedValue(expectedOrder);
const getOrderSpy = jest.spyOn(orchestrator, "getOrder").mockResolvedValue(expectedOrder);
const result = await orchestrator.getOrderForUser(expectedOrder.id, "user-1");
expect(result).toBe(expectedOrder);
expect(orchestrator.getOrder).toHaveBeenCalledWith(expectedOrder.id);
expect(getOrderSpy).toHaveBeenCalledWith(expectedOrder.id);
});
it("throws NotFound when the user mapping lacks a Salesforce account", async () => {

View File

@ -18,8 +18,8 @@ export class OrdersCacheService {
// Hybrid approach: CDC for real-time invalidation + TTL for backup cleanup
// Primary: CDC events invalidate cache when customer-facing fields change
// Backup: TTL expires unused cache entries (memory management)
private readonly SUMMARY_TTL_SECONDS = 3600; // 1 hour - order lists
private readonly DETAIL_TTL_SECONDS = 7200; // 2 hours - individual orders
private readonly SUMMARY_TTL_SECONDS = 3600; // 1 hour - order lists
private readonly DETAIL_TTL_SECONDS = 7200; // 2 hours - individual orders
private readonly metrics: OrdersCacheMetrics = {
summaries: { hits: 0, misses: 0 },
@ -109,19 +109,19 @@ export class OrdersCacheService {
// 3. No cache hit and no in-flight request - fetch fresh data
this.metrics[bucket].misses++;
const fetchPromise = (async () => {
try {
const fresh = await fetcher();
const valueToStore = allowNull ? (fresh ?? null) : fresh;
// Store in Redis for future requests
if (ttlSeconds === null) {
await this.cache.set(key, this.wrapCachedValue(valueToStore));
} else {
await this.cache.set(key, this.wrapCachedValue(valueToStore), ttlSeconds);
}
return fresh;
} finally {
// Clean up: Remove from in-flight map when done (success or failure)

View File

@ -120,4 +120,3 @@ export class UsersFacade {
return sanitized;
}
}

View File

@ -63,4 +63,3 @@ export class UserAuthRepository {
}
}
}

View File

@ -124,7 +124,10 @@ export class UserProfileService {
return this.getProfile(validId);
} catch (error) {
const msg = getErrorMessage(error);
this.logger.error({ userId: validId, error: msg }, "Failed to update customer profile in WHMCS");
this.logger.error(
{ userId: validId, error: msg },
"Failed to update customer profile in WHMCS"
);
if (msg.includes("WHMCS API Error")) {
throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
@ -398,4 +401,3 @@ export class UserProfileService {
}
}
}

View File

@ -16,10 +16,7 @@ const nextConfig = {
output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
// Ensure workspace packages are transpiled correctly
transpilePackages: [
"@customer-portal/domain",
"@customer-portal/validation",
],
transpilePackages: ["@customer-portal/domain", "@customer-portal/validation"],
// Tell Next to NOT bundle these server-only libs
serverExternalPackages: [

View File

@ -22,7 +22,7 @@ export function usePaymentRefresh({
hasMethods,
}: UsePaymentRefreshOptions) {
const { isAuthenticated } = useAuthSession();
const hideToastTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideToastTimeout = useRef<number | null>(null);
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
visible: false,
text: "",

View File

@ -13,10 +13,7 @@ import {
deriveOrderStatusDescriptor,
getServiceCategory,
} from "@/features/orders/utils/order-presenters";
import {
buildOrderDisplayItems,
summarizeOrderDisplayItems,
} from "@/features/orders/utils/order-display";
import { buildOrderDisplayItems } from "@/features/orders/utils/order-display";
import type { OrderSummary } from "@customer-portal/domain/orders";
import { cn } from "@/lib/utils/cn";
@ -70,10 +67,10 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
() => buildOrderDisplayItems(order.itemsSummary),
[order.itemsSummary]
);
// Use just the order type as the service name
const serviceName = order.orderType ? `${order.orderType} Service` : "Service Order";
const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount);
const createdDate = useMemo(() => {
if (!order.createdDate) return null;
@ -131,7 +128,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span className="font-medium">#{order.orderNumber || String(order.id).slice(-8)}</span>
<span className="font-medium">
#{order.orderNumber || String(order.id).slice(-8)}
</span>
<span></span>
<span>{formattedCreatedDate || "—"}</span>
</div>
@ -149,13 +148,15 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
)}
</div>
</div>
{/* Right section: Pricing */}
{showPricing && (
<div className="flex items-start gap-4 flex-shrink-0">
{totals.monthlyTotal > 0 && (
<div className="text-right">
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">Monthly</p>
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">
Monthly
</p>
<p className="text-xl font-bold text-gray-900">
¥{totals.monthlyTotal.toLocaleString()}
</p>
@ -163,7 +164,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
)}
{totals.oneTimeTotal > 0 && (
<div className="text-right">
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">One-Time</p>
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">
One-Time
</p>
<p className="text-lg font-bold text-gray-900">
¥{totals.oneTimeTotal.toLocaleString()}
</p>
@ -173,6 +176,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
)}
</div>
</div>
{footer && <div className="border-t border-slate-100 bg-slate-50 px-6 py-3">{footer}</div>}
</article>
);
}

View File

@ -24,7 +24,7 @@ export function OrderCardSkeleton() {
</div>
</div>
</div>
{/* Right section: Pricing */}
<div className="flex items-start gap-4 flex-shrink-0">
<div className="text-right space-y-1">
@ -39,4 +39,3 @@ export function OrderCardSkeleton() {
}
export default OrderCardSkeleton;

View File

@ -31,7 +31,10 @@ type GetOrderByIdOptions = {
signal?: AbortSignal;
};
async function getOrderById(orderId: string, options: GetOrderByIdOptions = {}): Promise<OrderDetails> {
async function getOrderById(
orderId: string,
options: GetOrderByIdOptions = {}
): Promise<OrderDetails> {
const response = await apiClient.GET("/api/orders/{sfOrderId}", {
params: { path: { sfOrderId: orderId } },
signal: options.signal,

View File

@ -1,7 +1,12 @@
import type { OrderItemSummary } from "@customer-portal/domain/orders";
import { normalizeBillingCycle } from "@/features/orders/utils/order-presenters";
export type OrderDisplayItemCategory = "service" | "installation" | "addon" | "activation" | "other";
export type OrderDisplayItemCategory =
| "service"
| "installation"
| "addon"
| "activation"
| "other";
export type OrderDisplayItemChargeKind = "monthly" | "one-time" | "other";
@ -25,19 +30,6 @@ export interface OrderDisplayItem {
isBundle: boolean;
}
interface OrderItemGroup {
indices: number[];
items: OrderItemSummary[];
}
const CATEGORY_ORDER: OrderDisplayItemCategory[] = [
"service",
"installation",
"addon",
"activation",
"other",
];
const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = {
monthly: 0,
"one-time": 1,
@ -46,8 +38,7 @@ const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = {
const MONTHLY_SUFFIX = "/ month";
const normalizeItemClass = (itemClass?: string | null): string =>
(itemClass ?? "").toLowerCase();
const normalizeItemClass = (itemClass?: string | null): string => (itemClass ?? "").toLowerCase();
const resolveCategory = (item: OrderItemSummary): OrderDisplayItemCategory => {
const normalizedClass = normalizeItemClass(item.itemClass);
@ -65,20 +56,13 @@ const coerceNumber = (value: unknown): number => {
return 0;
};
const buildOrderItemId = (group: OrderItemGroup, fallbackIndex: number): string => {
const identifiers = group.items
.map(item => item.productId || item.sku || item.name)
.filter((id): id is string => typeof id === "string" && id.length > 0);
if (identifiers.length === 0) {
return `order-item-${fallbackIndex}`;
}
return identifiers.join("|");
};
const aggregateCharges = (items: OrderItemSummary[]): OrderDisplayItemCharge[] => {
const accumulator = new Map<
string,
OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind }
>();
const aggregateCharges = (group: OrderItemGroup): OrderDisplayItemCharge[] => {
const accumulator = new Map<string, OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind }>();
for (const item of group.items) {
for (const item of items) {
const amount = coerceNumber(item.totalPrice ?? item.unitPrice);
const normalizedCycle = normalizeBillingCycle(item.billingCycle ?? undefined);
@ -117,81 +101,9 @@ const aggregateCharges = (group: OrderItemGroup): OrderDisplayItemCharge[] => {
});
};
const buildGroupName = (group: OrderItemGroup): string => {
// For bundles, combine the names
if (group.items.length > 1) {
return group.items
.map(item => item.productName || item.name || "Item")
.join(" + ");
}
const fallbackItem = group.items[0];
return fallbackItem?.productName || fallbackItem?.name || "Service item";
};
const determinePrimaryCategory = (group: OrderItemGroup): OrderDisplayItemCategory => {
const categories = group.items.map(resolveCategory);
for (const preferred of CATEGORY_ORDER) {
if (categories.includes(preferred)) {
return preferred;
}
}
return "other";
};
const collectCategories = (group: OrderItemGroup): OrderDisplayItemCategory[] => {
const unique = new Set<OrderDisplayItemCategory>();
group.items.forEach(item => unique.add(resolveCategory(item)));
return Array.from(unique);
};
const isIncludedGroup = (charges: OrderDisplayItemCharge[]): boolean =>
charges.every(charge => charge.amount <= 0);
const buildOrderItemGroup = (items: OrderItemSummary[]): OrderItemGroup[] => {
const groups: OrderItemGroup[] = [];
const usedIndices = new Set<number>();
const productIndex = new Map<string, number[]>();
items.forEach((item, index) => {
const key = item.productId || item.sku;
if (!key) return;
const existing = productIndex.get(key);
if (existing) {
existing.push(index);
} else {
productIndex.set(key, [index]);
}
});
items.forEach((item, index) => {
if (usedIndices.has(index)) {
return;
}
if (item.isBundledAddon && item.bundledAddonId) {
const partnerCandidates = productIndex.get(item.bundledAddonId) ?? [];
const partnerIndex = partnerCandidates.find(candidate => candidate !== index && !usedIndices.has(candidate));
if (typeof partnerIndex === "number") {
const partner = items[partnerIndex];
if (partner) {
const orderedIndices = partnerIndex < index ? [partnerIndex, index] : [index, partnerIndex];
const groupItems = orderedIndices.map(i => items[i]);
groups.push({ indices: orderedIndices, items: groupItems });
usedIndices.add(index);
usedIndices.add(partnerIndex);
return;
}
}
}
groups.push({ indices: [index], items: [item] });
usedIndices.add(index);
});
return groups.sort((a, b) => a.indices[0] - b.indices[0]);
};
export function buildOrderDisplayItems(
items: OrderItemSummary[] | null | undefined
): OrderDisplayItem[] {
@ -201,9 +113,9 @@ export function buildOrderDisplayItems(
// Map items to display format - keep the order from the backend
return items.map((item, index) => {
const charges = aggregateCharges({ indices: [index], items: [item] });
const charges = aggregateCharges([item]);
const isBundled = Boolean(item.isBundledAddon);
return {
id: item.productId || item.sku || `order-item-${index}`,
name: item.productName || item.name || "Service item",
@ -219,10 +131,7 @@ export function buildOrderDisplayItems(
});
}
export function summarizeOrderDisplayItems(
items: OrderDisplayItem[],
fallback: string
): string {
export function summarizeOrderDisplayItems(items: OrderDisplayItem[], fallback: string): string {
if (items.length === 0) {
return fallback;
}
@ -234,5 +143,3 @@ export function summarizeOrderDisplayItems(
return `${primary.name} +${rest.length} more`;
}

View File

@ -22,7 +22,10 @@ import {
import { StatusPill } from "@/components/atoms/status-pill";
import { Button } from "@/components/atoms/button";
import { ordersService } from "@/features/orders/services/orders.service";
import { useOrderUpdates, type OrderUpdateEventPayload } from "@/features/orders/hooks/useOrderUpdates";
import {
useOrderUpdates,
type OrderUpdateEventPayload,
} from "@/features/orders/hooks/useOrderUpdates";
import {
calculateOrderTotals,
deriveOrderStatusDescriptor,
@ -209,8 +212,8 @@ export function OrderDetailContainer() {
}
}, [data?.orderType, serviceCategory]);
const showFeeNotice = displayItems.some(item =>
item.categories.includes("installation") || item.categories.includes("activation")
const showFeeNotice = displayItems.some(
item => item.categories.includes("installation") || item.categories.includes("activation")
);
const fetchOrder = useCallback(async (): Promise<void> => {
@ -325,21 +328,28 @@ export function OrderDetailContainer() {
{/* Left: Title & Date */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-gray-900">{serviceLabel}</h2>
{statusDescriptor && (
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
)}
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
{serviceIcon}
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold text-gray-900">{serviceLabel}</h2>
{statusDescriptor && (
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
)}
</div>
{placedDate && <p className="text-sm text-gray-500">{placedDate}</p>}
</div>
</div>
{placedDate && (
<p className="text-sm text-gray-500">{placedDate}</p>
)}
</div>
{/* Right: Pricing Section */}
<div className="flex items-start gap-6 sm:gap-8">
{totals.monthlyTotal > 0 && (
<div className="text-right">
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">Monthly</p>
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">
Monthly
</p>
<p className="text-3xl font-bold text-gray-900">
{yenFormatter.format(totals.monthlyTotal)}
</p>
@ -347,7 +357,9 @@ export function OrderDetailContainer() {
)}
{totals.oneTimeTotal > 0 && (
<div className="text-right">
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">One-Time</p>
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">
One-Time
</p>
<p className="text-3xl font-bold text-gray-900">
{yenFormatter.format(totals.oneTimeTotal)}
</p>
@ -361,7 +373,12 @@ export function OrderDetailContainer() {
<div className="flex flex-col gap-6">
{/* Order Items Section */}
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-700" style={{ letterSpacing: '0.1em' }}>Order Details</h3>
<h3
className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-700"
style={{ letterSpacing: "0.1em" }}
>
Order Details
</h3>
<div className="space-y-4">
{displayItems.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-gray-500">
@ -369,7 +386,8 @@ export function OrderDetailContainer() {
</div>
) : (
displayItems.map((item, itemIndex) => {
const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
const categoryConfig =
CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
const Icon = categoryConfig.icon;
const style = getItemVisualStyle(item);
@ -384,10 +402,12 @@ export function OrderDetailContainer() {
>
{/* Icon + Title & Category | Price */}
<div className="flex flex-1 items-start gap-3">
<div className={cn(
"flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg",
style.icon
)}>
<div
className={cn(
"flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg",
style.icon
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="flex flex-1 items-baseline justify-between gap-4 sm:items-center">
@ -405,11 +425,16 @@ export function OrderDetailContainer() {
const descriptor = describeCharge(charge);
if (charge.amount > 0) {
return (
<div key={`${item.id}-charge-${index}`} className="whitespace-nowrap text-lg">
<div
key={`${item.id}-charge-${index}`}
className="whitespace-nowrap text-lg"
>
<span className="font-bold text-gray-900">
{yenFormatter.format(charge.amount)}
</span>
<span className="ml-2 text-xs font-medium text-gray-500">{descriptor}</span>
<span className="ml-2 text-xs font-medium text-gray-500">
{descriptor}
</span>
</div>
);
}
@ -441,9 +466,7 @@ export function OrderDetailContainer() {
<ClockIcon className="h-5 w-5 flex-shrink-0 text-blue-600" />
<div>
<p className="text-sm font-semibold text-blue-900">Next Steps</p>
<p className="mt-1 text-sm text-blue-800">
{statusDescriptor.nextAction}
</p>
<p className="mt-1 text-sm text-blue-800">{statusDescriptor.nextAction}</p>
</div>
</div>
</div>
@ -454,9 +477,14 @@ export function OrderDetailContainer() {
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-600" />
<div>
<p className="text-sm font-semibold text-amber-900">Installation Fee Notice</p>
<p className="text-sm font-semibold text-amber-900">
Installation Fee Notice
</p>
<p className="mt-1 text-sm text-amber-800">
Standard installation is included. Additional charges may apply for weekend scheduling, express service, or specialized equipment installation. Any extra fees will be discussed and approved by you before work begins.
Standard installation is included. Additional charges may apply for
weekend scheduling, express service, or specialized equipment
installation. Any extra fees will be discussed and approved by you before
work begins.
</p>
</div>
</div>
@ -476,4 +504,3 @@ export function OrderDetailContainer() {
}
export default OrderDetailContainer;

View File

@ -41,7 +41,7 @@ export function SimFeatureToggles({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const successTimerRef = useRef<number | null>(null);
useEffect(() => {
setVm(initial.vm);

View File

@ -76,10 +76,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
setError(err instanceof Error ? err.message : "Failed to load SIM information");
} finally {
if (controller.signal.aborted || !isMountedRef.current) {
return;
if (!controller.signal.aborted && isMountedRef.current) {
setLoading(false);
}
setLoading(false);
}
}, [subscriptionId]);

View File

@ -22,18 +22,20 @@ import { logger } from "@/lib/logger";
async function handleApiError(response: Response): Promise<void> {
// Don't import useAuthStore at module level to avoid circular dependencies
// We'll handle auth errors by dispatching a custom event that the auth system can listen to
if (response.status === 401) {
logger.warn("Received 401 Unauthorized response - triggering logout");
// Dispatch a custom event that the auth system will listen to
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:unauthorized", {
detail: { url: response.url, status: response.status }
}));
window.dispatchEvent(
new CustomEvent("auth:unauthorized", {
detail: { url: response.url, status: response.status },
})
);
}
}
// Still throw the error so the calling code can handle it
let body: unknown;
let message = response.statusText || `Request failed with status ${response.status}`;

View File

@ -6,12 +6,7 @@ import { currencyService } from "@/lib/services/currency.service";
import { FALLBACK_CURRENCY, type WhmcsCurrency } from "@customer-portal/domain/billing";
export function useCurrency() {
const {
data,
isLoading,
isError,
error,
} = useQuery<WhmcsCurrency>({
const { data, isLoading, isError, error } = useQuery<WhmcsCurrency>({
queryKey: queryKeys.currency.default(),
queryFn: () => currencyService.getDefaultCurrency(),
staleTime: 60 * 60 * 1000, // cache currency for 1 hour

View File

@ -78,7 +78,6 @@ export const logger = new Logger();
export const log = {
info: (message: string, meta?: LogMeta) => logger.info(message, meta),
warn: (message: string, meta?: LogMeta) => logger.warn(message, meta),
error: (message: string, error?: unknown, meta?: LogMeta) =>
logger.error(message, error, meta),
error: (message: string, error?: unknown, meta?: LogMeta) => logger.error(message, error, meta),
debug: (message: string, meta?: LogMeta) => logger.debug(message, meta),
};

View File

@ -1,3 +0,0 @@
export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod";
export { ZodValidationExceptionFilter } from "./zod-exception.filter";
//# sourceMappingURL=index.d.ts.map

View File

@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AACrF,OAAO,EAAE,4BAA4B,EAAE,MAAM,wBAAwB,CAAC"}

View File

@ -1,10 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ZodValidationExceptionFilter = exports.ZodValidationException = exports.createZodDto = exports.ZodValidationPipe = void 0;
var nestjs_zod_1 = require("nestjs-zod");
Object.defineProperty(exports, "ZodValidationPipe", { enumerable: true, get: function () { return nestjs_zod_1.ZodValidationPipe; } });
Object.defineProperty(exports, "createZodDto", { enumerable: true, get: function () { return nestjs_zod_1.createZodDto; } });
Object.defineProperty(exports, "ZodValidationException", { enumerable: true, get: function () { return nestjs_zod_1.ZodValidationException; } });
var zod_exception_filter_1 = require("./zod-exception.filter");
Object.defineProperty(exports, "ZodValidationExceptionFilter", { enumerable: true, get: function () { return zod_exception_filter_1.ZodValidationExceptionFilter; } });
//# sourceMappingURL=index.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,yCAAqF;AAA5E,+GAAA,iBAAiB,OAAA;AAAE,0GAAA,YAAY,OAAA;AAAE,oHAAA,sBAAsB,OAAA;AAChE,+DAAsE;AAA7D,oIAAA,4BAA4B,OAAA"}

View File

@ -1,11 +0,0 @@
import { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ZodValidationException } from "nestjs-zod";
export declare class ZodValidationExceptionFilter implements ExceptionFilter {
private readonly logger;
constructor(logger: Logger);
catch(exception: ZodValidationException, host: ArgumentsHost): void;
private isZodError;
private mapIssues;
}
//# sourceMappingURL=zod-exception.filter.d.ts.map

View File

@ -1 +0,0 @@
{"version":3,"file":"zod-exception.filter.d.ts","sourceRoot":"","sources":["zod-exception.filter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAS,eAAe,EAAsB,MAAM,gBAAgB,CAAC;AAE3F,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AASpD,qBACa,4BAA6B,YAAW,eAAe;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,MAAM;IAE3D,KAAK,CAAC,SAAS,EAAE,sBAAsB,EAAE,IAAI,EAAE,aAAa,GAAG,IAAI;IAsCnE,OAAO,CAAC,UAAU;IAMlB,OAAO,CAAC,SAAS;CAOlB"}

View File

@ -1,75 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ZodValidationExceptionFilter = void 0;
const common_1 = require("@nestjs/common");
const nestjs_pino_1 = require("nestjs-pino");
const nestjs_zod_1 = require("nestjs-zod");
let ZodValidationExceptionFilter = class ZodValidationExceptionFilter {
logger;
constructor(logger) {
this.logger = logger;
}
catch(exception, host) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const rawZodError = exception.getZodError();
let issues = [];
if (!this.isZodError(rawZodError)) {
this.logger.error("ZodValidationException did not contain a ZodError", {
path: request.url,
method: request.method,
providedType: typeof rawZodError,
});
}
else {
issues = this.mapIssues(rawZodError.issues);
}
this.logger.warn("Request validation failed", {
path: request.url,
method: request.method,
issues,
});
response.status(common_1.HttpStatus.BAD_REQUEST).json({
success: false,
error: {
code: "VALIDATION_FAILED",
message: "Request validation failed",
details: {
issues,
timestamp: new Date().toISOString(),
path: request.url,
},
},
});
}
isZodError(error) {
return Boolean(error && typeof error === "object" && Array.isArray(error.issues));
}
mapIssues(issues) {
return issues.map(issue => ({
path: issue.path.join(".") || "root",
message: issue.message,
code: issue.code,
}));
}
};
exports.ZodValidationExceptionFilter = ZodValidationExceptionFilter;
exports.ZodValidationExceptionFilter = ZodValidationExceptionFilter = __decorate([
(0, common_1.Catch)(nestjs_zod_1.ZodValidationException),
__param(0, (0, common_1.Inject)(nestjs_pino_1.Logger)),
__metadata("design:paramtypes", [nestjs_pino_1.Logger])
], ZodValidationExceptionFilter);
//# sourceMappingURL=zod-exception.filter.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"zod-exception.filter.js","sourceRoot":"","sources":["zod-exception.filter.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAA2F;AAE3F,6CAAqC;AACrC,2CAAoD;AAU7C,IAAM,4BAA4B,GAAlC,MAAM,4BAA4B;IACM;IAA7C,YAA6C,MAAc;QAAd,WAAM,GAAN,MAAM,CAAQ;IAAG,CAAC;IAE/D,KAAK,CAAC,SAAiC,EAAE,IAAmB;QAC1D,MAAM,GAAG,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;QAChC,MAAM,QAAQ,GAAG,GAAG,CAAC,WAAW,EAAY,CAAC;QAC7C,MAAM,OAAO,GAAG,GAAG,CAAC,UAAU,EAAW,CAAC;QAE1C,MAAM,WAAW,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC;QAC5C,IAAI,MAAM,GAAuB,EAAE,CAAC;QAEpC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;YAClC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,mDAAmD,EAAE;gBACrE,IAAI,EAAE,OAAO,CAAC,GAAG;gBACjB,MAAM,EAAE,OAAO,CAAC,MAAM;gBACtB,YAAY,EAAE,OAAO,WAAW;aACjC,CAAC,CAAC;QACL,CAAC;aAAM,CAAC;YACN,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,2BAA2B,EAAE;YAC5C,IAAI,EAAE,OAAO,CAAC,GAAG;YACjB,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,MAAM;SACP,CAAC,CAAC;QAEH,QAAQ,CAAC,MAAM,CAAC,mBAAU,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC;YAC3C,OAAO,EAAE,KAAc;YACvB,KAAK,EAAE;gBACL,IAAI,EAAE,mBAAmB;gBACzB,OAAO,EAAE,2BAA2B;gBACpC,OAAO,EAAE;oBACP,MAAM;oBACN,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;oBACnC,IAAI,EAAE,OAAO,CAAC,GAAG;iBAClB;aACF;SACF,CAAC,CAAC;IACL,CAAC;IAEO,UAAU,CAAC,KAAc;QAC/B,OAAO,OAAO,CACZ,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAE,KAA8B,CAAC,MAAM,CAAC,CAC5F,CAAC;IACJ,CAAC;IAEO,SAAS,CAAC,MAAkB;QAClC,OAAO,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAC1B,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,MAAM;YACpC,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,IAAI,EAAE,KAAK,CAAC,IAAI;SACjB,CAAC,CAAC,CAAC;IACN,CAAC;CACF,CAAA;AAtDY,oEAA4B;uCAA5B,4BAA4B;IADxC,IAAA,cAAK,EAAC,mCAAsB,CAAC;IAEf,WAAA,IAAA,eAAM,EAAC,oBAAM,CAAC,CAAA;qCAA0B,oBAAM;GADhD,4BAA4B,CAsDxC"}