From ff55639b2da78c8cd1f1661f58f202b5b00a3943 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 17 Nov 2025 11:49:58 +0900 Subject: [PATCH] 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. --- .editorconfig | 14 +- apps/bff/src/core/config/env.validation.ts | 2 +- .../salesforce-request-queue.service.ts | 3 +- .../controllers/csrf.controller.spec.ts | 6 +- .../events/catalog-cdc.subscriber.ts | 28 +- .../salesforce/events/events.module.ts | 4 +- .../salesforce/events/order-cdc.subscriber.ts | 72 ++-- .../guards/salesforce-read-throttle.guard.ts | 1 - .../guards/salesforce-write-throttle.guard.ts | 1 - .../services/salesforce-account.service.ts | 6 +- .../salesforce-connection.service.spec.ts | 44 ++- .../services/salesforce-connection.service.ts | 344 ++++++++-------- .../services/salesforce-order.service.ts | 20 +- .../services/whmcs-http-client.service.ts | 31 +- .../whmcs/services/whmcs-order.service.ts | 10 +- .../auth/constants/portal.constants.ts | 4 +- .../workflows/signup-workflow.service.ts | 14 +- .../auth/presentation/http/auth.controller.ts | 7 +- .../guards/failed-login-throttle.guard.ts | 5 +- .../http/guards/global-auth.guard.ts | 3 +- .../bff/src/modules/catalog/catalog.module.ts | 7 +- .../catalog/services/base-catalog.service.ts | 2 +- .../catalog/services/catalog-cache.service.ts | 24 +- .../services/internet-catalog.service.ts | 4 +- .../catalog/services/sim-catalog.service.ts | 8 +- apps/bff/src/modules/orders/orders.module.ts | 7 +- .../orders/services/checkout.service.spec.ts | 7 +- .../orders/services/order-builder.service.ts | 6 +- .../order-fulfillment-orchestrator.service.ts | 373 +++++++++--------- .../order-orchestrator.service.spec.ts | 4 +- .../orders/services/orders-cache.service.ts | 10 +- .../modules/users/application/users.facade.ts | 1 - .../users/infra/user-auth.repository.ts | 1 - .../users/infra/user-profile.service.ts | 6 +- apps/portal/next.config.mjs | 5 +- .../billing/hooks/usePaymentRefresh.ts | 2 +- .../features/orders/components/OrderCard.tsx | 24 +- .../orders/components/OrderCardSkeleton.tsx | 3 +- .../orders/services/orders.service.ts | 5 +- .../features/orders/utils/order-display.ts | 125 +----- .../src/features/orders/views/OrderDetail.tsx | 79 ++-- .../components/SimFeatureToggles.tsx | 2 +- .../components/SimManagementSection.tsx | 5 +- apps/portal/src/lib/api/index.ts | 14 +- apps/portal/src/lib/hooks/useCurrency.ts | 7 +- apps/portal/src/lib/logger.ts | 3 +- packages/validation/src/nestjs/index.d.ts | 3 - packages/validation/src/nestjs/index.d.ts.map | 1 - packages/validation/src/nestjs/index.js | 10 - packages/validation/src/nestjs/index.js.map | 1 - .../src/nestjs/zod-exception.filter.d.ts | 11 - .../src/nestjs/zod-exception.filter.d.ts.map | 1 - .../src/nestjs/zod-exception.filter.js | 75 ---- .../src/nestjs/zod-exception.filter.js.map | 1 - 54 files changed, 645 insertions(+), 811 deletions(-) mode change 120000 => 100644 .editorconfig delete mode 100644 packages/validation/src/nestjs/index.d.ts delete mode 100644 packages/validation/src/nestjs/index.d.ts.map delete mode 100644 packages/validation/src/nestjs/index.js delete mode 100644 packages/validation/src/nestjs/index.js.map delete mode 100644 packages/validation/src/nestjs/zod-exception.filter.d.ts delete mode 100644 packages/validation/src/nestjs/zod-exception.filter.d.ts.map delete mode 100644 packages/validation/src/nestjs/zod-exception.filter.js delete mode 100644 packages/validation/src/nestjs/zod-exception.filter.js.map diff --git a/.editorconfig b/.editorconfig deleted file mode 120000 index 3e02dfc3..00000000 --- a/.editorconfig +++ /dev/null @@ -1 +0,0 @@ -config/.editorconfig \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..c7b9ed54 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index 0c9dc2c9..5b1f9718 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -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"), diff --git a/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts b/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts index 70d24a2b..41068ee5 100644 --- a/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts +++ b/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts @@ -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, diff --git a/apps/bff/src/core/security/controllers/csrf.controller.spec.ts b/apps/bff/src/core/security/controllers/csrf.controller.spec.ts index 81ce98c5..3f66d2d5 100644 --- a/apps/bff/src/core/security/controllers/csrf.controller.spec.ts +++ b/apps/bff/src/core/security/controllers/csrf.controller.spec.ts @@ -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", () => { diff --git a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts index 3a11e533..02510201 100644 --- a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts @@ -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(); } } diff --git a/apps/bff/src/integrations/salesforce/events/events.module.ts b/apps/bff/src/integrations/salesforce/events/events.module.ts index b557b606..5e2f5411 100644 --- a/apps/bff/src/integrations/salesforce/events/events.module.ts +++ b/apps/bff/src/integrations/salesforce/events/events.module.ts @@ -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 {} diff --git a/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts index 159c17e0..ea09e83c 100644 --- a/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts @@ -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("SF_ORDER_CDC_CHANNEL")?.trim() || - "/data/OrderChangeEvent"; + this.config.get("SF_ORDER_CDC_CHANNEL")?.trim() || "/data/OrderChangeEvent"; const orderItemChannel = - this.config.get("SF_ORDER_ITEM_CDC_CHANNEL")?.trim() || - "/data/OrderItemChangeEvent"; + this.config.get("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 { + 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 - ): { - changedFields?: unknown; - recordIds?: unknown; - entityName?: unknown; - changeType?: unknown; - } | undefined { + private extractChangeEventHeader(payload: Record): + | { + changedFields?: unknown; + recordIds?: unknown; + entityName?: unknown; + changeType?: unknown; + } + | undefined { const header = payload["ChangeEventHeader"]; if (header && typeof header === "object") { return header as { diff --git a/apps/bff/src/integrations/salesforce/guards/salesforce-read-throttle.guard.ts b/apps/bff/src/integrations/salesforce/guards/salesforce-read-throttle.guard.ts index 25369ab3..23f3c1cd 100644 --- a/apps/bff/src/integrations/salesforce/guards/salesforce-read-throttle.guard.ts +++ b/apps/bff/src/integrations/salesforce/guards/salesforce-read-throttle.guard.ts @@ -37,4 +37,3 @@ export class SalesforceReadThrottleGuard implements CanActivate { ); } } - diff --git a/apps/bff/src/integrations/salesforce/guards/salesforce-write-throttle.guard.ts b/apps/bff/src/integrations/salesforce/guards/salesforce-write-throttle.guard.ts index ced5b1ba..a21f47ae 100644 --- a/apps/bff/src/integrations/salesforce/guards/salesforce-write-throttle.guard.ts +++ b/apps/bff/src/integrations/salesforce/guards/salesforce-write-throttle.guard.ts @@ -37,4 +37,3 @@ export class SalesforceWriteThrottleGuard implements CanActivate { ); } } - diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts index 2e07d052..185e1005 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts @@ -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 & { Id: string }); this.logger.debug("Updated Salesforce account portal fields", { accountId: validAccountId, diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.spec.ts b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.spec.ts index 50bb1bc7..fdc55c1a 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.spec.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.spec.ts @@ -9,10 +9,24 @@ describe("SalesforceConnection", () => { get: jest.fn(), } as unknown as ConfigService; - const requestQueue: Partial = { - execute: jest.fn().mockImplementation(async (fn) => fn()), - executeHighPriority: jest.fn().mockImplementation(async (fn) => fn()), - }; + const execute = jest.fn< + Promise, + [fn: () => Promise, options?: Record] + >(async (fn): Promise => { + return await fn(); + }); + + const executeHighPriority = jest.fn< + Promise, + [fn: () => Promise, options?: Record] + >(async (fn): Promise => { + 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 }, "ensureConnected").mockResolvedValue(); + jest + .spyOn(service as unknown as { ensureConnected(): Promise }, "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" }); }); }); diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts index fe849bd4..c960e871 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts @@ -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 | Record[]; - - 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 | Record[]; + + 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 { 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 } + ); } /** diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts index 1fcf61f3..e6da6125 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts @@ -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} diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts index 5b39c3fb..2d411114 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts @@ -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, - options: WhmcsRequestOptions + params: Record ): 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}`); } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts index 8a541e3c..be0daee4 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts @@ -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 diff --git a/apps/bff/src/modules/auth/constants/portal.constants.ts b/apps/bff/src/modules/auth/constants/portal.constants.ts index 32f05e3f..1adab220 100644 --- a/apps/bff/src/modules/auth/constants/portal.constants.ts +++ b/apps/bff/src/modules/auth/constants/portal.constants.ts @@ -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; diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts index 70613600..9dde8d23 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts @@ -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, diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index 0bd40f1b..45f04de0 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -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. } } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts index 26795e7f..5f66d3de 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts @@ -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; diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index a1364e30..25278a7e 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -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 & { +type RequestBase = Omit; +type RequestWithCookies = RequestBase & { cookies?: Record; }; type RequestWithRoute = RequestWithCookies & { diff --git a/apps/bff/src/modules/catalog/catalog.module.ts b/apps/bff/src/modules/catalog/catalog.module.ts index 9f79f55e..b585d621 100644 --- a/apps/bff/src/modules/catalog/catalog.module.ts +++ b/apps/bff/src/modules/catalog/catalog.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/catalog/services/base-catalog.service.ts b/apps/bff/src/modules/catalog/services/base-catalog.service.ts index 832e1f31..a0bd1985 100644 --- a/apps/bff/src/modules/catalog/services/base-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/base-catalog.service.ts @@ -41,7 +41,7 @@ export class BaseCatalogService { ): Promise { try { const res = (await this.sf.query(soql, { - label: `catalog:${context.replace(/\s+/g, "_" ).toLowerCase()}`, + label: `catalog:${context.replace(/\s+/g, "_").toLowerCase()}`, })) as SalesforceResponse; return res.records ?? []; } catch (error: unknown) { diff --git a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts index 03d35f35..f1d22801 100644 --- a/apps/bff/src/modules/catalog/services/catalog-cache.service.ts +++ b/apps/bff/src/modules/catalog/services/catalog-cache.service.ts @@ -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 { allowNull?: boolean; - resolveDependencies?: (value: T) => CacheDependencies | Promise | undefined; + resolveDependencies?: ( + value: T + ) => CacheDependencies | Promise | undefined; } diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index 569234e6..690da7ee 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -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)), }), } ); diff --git a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts index 8335d1b0..ce412ac5 100644 --- a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts @@ -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)), }), } ); diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index 74597d65..049fd416 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/orders/services/checkout.service.spec.ts b/apps/bff/src/modules/orders/services/checkout.service.spec.ts index 13cb7036..16be95e2 100644 --- a/apps/bff/src/modules/orders/services/checkout.service.spec.ts +++ b/apps/bff/src/modules/orders/services/checkout.service.spec.ts @@ -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(); diff --git a/apps/bff/src/modules/orders/services/order-builder.service.ts b/apps/bff/src/modules/orders/services/order-builder.service.ts index ea7f9bdb..35728a32 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -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, diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index eeaa4534..6ff896b1 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -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) { diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.spec.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.spec.ts index b9214093..a9605361 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.spec.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.spec.ts @@ -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 () => { diff --git a/apps/bff/src/modules/orders/services/orders-cache.service.ts b/apps/bff/src/modules/orders/services/orders-cache.service.ts index 76338ceb..23c99d74 100644 --- a/apps/bff/src/modules/orders/services/orders-cache.service.ts +++ b/apps/bff/src/modules/orders/services/orders-cache.service.ts @@ -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) diff --git a/apps/bff/src/modules/users/application/users.facade.ts b/apps/bff/src/modules/users/application/users.facade.ts index 1ce2e2d6..02932861 100644 --- a/apps/bff/src/modules/users/application/users.facade.ts +++ b/apps/bff/src/modules/users/application/users.facade.ts @@ -120,4 +120,3 @@ export class UsersFacade { return sanitized; } } - diff --git a/apps/bff/src/modules/users/infra/user-auth.repository.ts b/apps/bff/src/modules/users/infra/user-auth.repository.ts index 390472b8..10ffdaae 100644 --- a/apps/bff/src/modules/users/infra/user-auth.repository.ts +++ b/apps/bff/src/modules/users/infra/user-auth.repository.ts @@ -63,4 +63,3 @@ export class UserAuthRepository { } } } - diff --git a/apps/bff/src/modules/users/infra/user-profile.service.ts b/apps/bff/src/modules/users/infra/user-profile.service.ts index 489f4e71..639b6a98 100644 --- a/apps/bff/src/modules/users/infra/user-profile.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -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 { } } } - diff --git a/apps/portal/next.config.mjs b/apps/portal/next.config.mjs index b6547d63..4a55bbe3 100644 --- a/apps/portal/next.config.mjs +++ b/apps/portal/next.config.mjs @@ -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: [ diff --git a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts index 82ba7477..0245318a 100644 --- a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts +++ b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts @@ -22,7 +22,7 @@ export function usePaymentRefresh({ hasMethods, }: UsePaymentRefreshOptions) { const { isAuthenticated } = useAuthSession(); - const hideToastTimeout = useRef | null>(null); + const hideToastTimeout = useRef(null); const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({ visible: false, text: "", diff --git a/apps/portal/src/features/orders/components/OrderCard.tsx b/apps/portal/src/features/orders/components/OrderCard.tsx index 7bb79291..58a845f5 100644 --- a/apps/portal/src/features/orders/components/OrderCard.tsx +++ b/apps/portal/src/features/orders/components/OrderCard.tsx @@ -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)
- #{order.orderNumber || String(order.id).slice(-8)} + + #{order.orderNumber || String(order.id).slice(-8)} + {formattedCreatedDate || "—"}
@@ -149,13 +148,15 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps) )} - + {/* Right section: Pricing */} {showPricing && (
{totals.monthlyTotal > 0 && (
-

Monthly

+

+ Monthly +

¥{totals.monthlyTotal.toLocaleString()}

@@ -163,7 +164,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps) )} {totals.oneTimeTotal > 0 && (
-

One-Time

+

+ One-Time +

¥{totals.oneTimeTotal.toLocaleString()}

@@ -173,6 +176,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps) )}
+ {footer &&
{footer}
} ); } diff --git a/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx b/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx index 406b9545..0630e839 100644 --- a/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx +++ b/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx @@ -24,7 +24,7 @@ export function OrderCardSkeleton() {
- + {/* Right section: Pricing */}
@@ -39,4 +39,3 @@ export function OrderCardSkeleton() { } export default OrderCardSkeleton; - diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts index f7e16e85..126661e0 100644 --- a/apps/portal/src/features/orders/services/orders.service.ts +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -31,7 +31,10 @@ type GetOrderByIdOptions = { signal?: AbortSignal; }; -async function getOrderById(orderId: string, options: GetOrderByIdOptions = {}): Promise { +async function getOrderById( + orderId: string, + options: GetOrderByIdOptions = {} +): Promise { const response = await apiClient.GET("/api/orders/{sfOrderId}", { params: { path: { sfOrderId: orderId } }, signal: options.signal, diff --git a/apps/portal/src/features/orders/utils/order-display.ts b/apps/portal/src/features/orders/utils/order-display.ts index 618e1d1f..7132fe12 100644 --- a/apps/portal/src/features/orders/utils/order-display.ts +++ b/apps/portal/src/features/orders/utils/order-display.ts @@ -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 = { monthly: 0, "one-time": 1, @@ -46,8 +38,7 @@ const CHARGE_ORDER: Record = { 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(); - - 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(); - 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(); - const productIndex = new Map(); - - 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`; } - - diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index 38fb9988..47827305 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -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 => { @@ -325,21 +328,28 @@ export function OrderDetailContainer() { {/* Left: Title & Date */}
-

{serviceLabel}

- {statusDescriptor && ( - - )} +
+ {serviceIcon} +
+
+
+

{serviceLabel}

+ {statusDescriptor && ( + + )} +
+ {placedDate &&

{placedDate}

} +
- {placedDate && ( -

{placedDate}

- )}
{/* Right: Pricing Section */}
{totals.monthlyTotal > 0 && (
-

Monthly

+

+ Monthly +

{yenFormatter.format(totals.monthlyTotal)}

@@ -347,7 +357,9 @@ export function OrderDetailContainer() { )} {totals.oneTimeTotal > 0 && (
-

One-Time

+

+ One-Time +

{yenFormatter.format(totals.oneTimeTotal)}

@@ -361,7 +373,12 @@ export function OrderDetailContainer() {
{/* Order Items Section */}
-

Order Details

+

+ Order Details +

{displayItems.length === 0 ? (
@@ -369,7 +386,8 @@ export function OrderDetailContainer() {
) : ( 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 */}
-
+
@@ -405,11 +425,16 @@ export function OrderDetailContainer() { const descriptor = describeCharge(charge); if (charge.amount > 0) { return ( -
+
{yenFormatter.format(charge.amount)} - {descriptor} + + {descriptor} +
); } @@ -441,9 +466,7 @@ export function OrderDetailContainer() {

Next Steps

-

- {statusDescriptor.nextAction} -

+

{statusDescriptor.nextAction}

@@ -454,9 +477,14 @@ export function OrderDetailContainer() {
-

Installation Fee Notice

+

+ Installation Fee Notice +

- 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.

@@ -476,4 +504,3 @@ export function OrderDetailContainer() { } export default OrderDetailContainer; - diff --git a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx index 1419a3c9..62e0e553 100644 --- a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx +++ b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx @@ -41,7 +41,7 @@ export function SimFeatureToggles({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const successTimerRef = useRef | null>(null); + const successTimerRef = useRef(null); useEffect(() => { setVm(initial.vm); diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index 990b0be4..d2413320 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -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]); diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index 82505b86..7e3b9937 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -22,18 +22,20 @@ import { logger } from "@/lib/logger"; async function handleApiError(response: Response): Promise { // 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}`; diff --git a/apps/portal/src/lib/hooks/useCurrency.ts b/apps/portal/src/lib/hooks/useCurrency.ts index 402c3746..e3a2fba1 100644 --- a/apps/portal/src/lib/hooks/useCurrency.ts +++ b/apps/portal/src/lib/hooks/useCurrency.ts @@ -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({ + const { data, isLoading, isError, error } = useQuery({ queryKey: queryKeys.currency.default(), queryFn: () => currencyService.getDefaultCurrency(), staleTime: 60 * 60 * 1000, // cache currency for 1 hour diff --git a/apps/portal/src/lib/logger.ts b/apps/portal/src/lib/logger.ts index a477ce01..16ce7e74 100644 --- a/apps/portal/src/lib/logger.ts +++ b/apps/portal/src/lib/logger.ts @@ -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), }; diff --git a/packages/validation/src/nestjs/index.d.ts b/packages/validation/src/nestjs/index.d.ts deleted file mode 100644 index b15965ad..00000000 --- a/packages/validation/src/nestjs/index.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod"; -export { ZodValidationExceptionFilter } from "./zod-exception.filter"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/validation/src/nestjs/index.d.ts.map b/packages/validation/src/nestjs/index.d.ts.map deleted file mode 100644 index 85395dfb..00000000 --- a/packages/validation/src/nestjs/index.d.ts.map +++ /dev/null @@ -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"} \ No newline at end of file diff --git a/packages/validation/src/nestjs/index.js b/packages/validation/src/nestjs/index.js deleted file mode 100644 index 007363a1..00000000 --- a/packages/validation/src/nestjs/index.js +++ /dev/null @@ -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 \ No newline at end of file diff --git a/packages/validation/src/nestjs/index.js.map b/packages/validation/src/nestjs/index.js.map deleted file mode 100644 index fa3e4d64..00000000 --- a/packages/validation/src/nestjs/index.js.map +++ /dev/null @@ -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"} \ No newline at end of file diff --git a/packages/validation/src/nestjs/zod-exception.filter.d.ts b/packages/validation/src/nestjs/zod-exception.filter.d.ts deleted file mode 100644 index 4a707e99..00000000 --- a/packages/validation/src/nestjs/zod-exception.filter.d.ts +++ /dev/null @@ -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 \ No newline at end of file diff --git a/packages/validation/src/nestjs/zod-exception.filter.d.ts.map b/packages/validation/src/nestjs/zod-exception.filter.d.ts.map deleted file mode 100644 index 6b08fc9f..00000000 --- a/packages/validation/src/nestjs/zod-exception.filter.d.ts.map +++ /dev/null @@ -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"} \ No newline at end of file diff --git a/packages/validation/src/nestjs/zod-exception.filter.js b/packages/validation/src/nestjs/zod-exception.filter.js deleted file mode 100644 index d4f2f6a2..00000000 --- a/packages/validation/src/nestjs/zod-exception.filter.js +++ /dev/null @@ -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 \ No newline at end of file diff --git a/packages/validation/src/nestjs/zod-exception.filter.js.map b/packages/validation/src/nestjs/zod-exception.filter.js.map deleted file mode 100644 index e11a94b6..00000000 --- a/packages/validation/src/nestjs/zod-exception.filter.js.map +++ /dev/null @@ -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"} \ No newline at end of file