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:
parent
b5533994c2
commit
ff55639b2d
@ -1 +0,0 @@
|
||||
config/.editorconfig
|
||||
13
.editorconfig
Normal file
13
.editorconfig
Normal 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
|
||||
@ -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,
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -37,4 +37,3 @@ export class SalesforceReadThrottleGuard implements CanActivate {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -37,4 +37,3 @@ export class SalesforceWriteThrottleGuard implements CanActivate {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,9 +281,10 @@ 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}`);
|
||||
}
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 & {
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 },
|
||||
@ -214,11 +214,7 @@ export class CatalogCacheService {
|
||||
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) {
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@ -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)),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -120,4 +120,3 @@ export class UsersFacade {
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -63,4 +63,3 @@ export class UserAuthRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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: "",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
@ -155,7 +154,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -39,4 +39,3 @@ export function OrderCardSkeleton() {
|
||||
}
|
||||
|
||||
export default OrderCardSkeleton;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,7 +113,7 @@ 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 {
|
||||
@ -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`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -28,9 +28,11 @@ async function handleApiError(response: Response): Promise<void> {
|
||||
|
||||
// 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 },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
3
packages/validation/src/nestjs/index.d.ts
vendored
3
packages/validation/src/nestjs/index.d.ts
vendored
@ -1,3 +0,0 @@
|
||||
export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod";
|
||||
export { ZodValidationExceptionFilter } from "./zod-exception.filter";
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
@ -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"}
|
||||
@ -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
|
||||
@ -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"}
|
||||
@ -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
|
||||
@ -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"}
|
||||
@ -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
|
||||
@ -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"}
|
||||
Loading…
x
Reference in New Issue
Block a user