diff --git a/apps/bff/src/infra/cache/cache.service.ts b/apps/bff/src/infra/cache/cache.service.ts index e9ba1d79..10bdd8f8 100644 --- a/apps/bff/src/infra/cache/cache.service.ts +++ b/apps/bff/src/infra/cache/cache.service.ts @@ -45,34 +45,62 @@ export class CacheService { } async delPattern(pattern: string): Promise { - let cursor = "0"; - const batch: string[] = []; + const pipeline = this.redis.pipeline(); + let pending = 0; + const flush = async () => { - if (batch.length > 0) { - const pipeline = this.redis.pipeline(); - for (const k of batch.splice(0, batch.length)) pipeline.del(k); - await pipeline.exec(); + if (pending === 0) { + return; } + await pipeline.exec(); + pending = 0; }; - do { - const [next, keys] = (await this.redis.scan( - cursor, - "MATCH", - pattern, - "COUNT", - 1000 - )) as unknown as [string, string[]]; - cursor = next; - if (keys && keys.length) { - batch.push(...keys); - if (batch.length >= 1000) { - await flush(); - } + + await this.scanPattern(pattern, async keys => { + keys.forEach(key => { + pipeline.del(key); + pending += 1; + }); + if (pending >= 1000) { + await flush(); } - } while (cursor !== "0"); + }); + await flush(); } + async countByPattern(pattern: string): Promise { + let total = 0; + await this.scanPattern(pattern, keys => { + total += keys.length; + }); + return total; + } + + async memoryUsageByPattern(pattern: string): Promise { + let total = 0; + await this.scanPattern(pattern, async keys => { + const pipeline = this.redis.pipeline(); + keys.forEach(key => { + pipeline.memory("usage", key); + }); + const results = await pipeline.exec(); + if (!results) { + return; + } + results.forEach(result => { + if (!result) { + return; + } + const [error, usage] = result as [Error | null, unknown]; + if (!error && typeof usage === "number") { + total += usage; + } + }); + }); + return total; + } + async exists(key: string): Promise { return (await this.redis.exists(key)) === 1; } @@ -91,4 +119,24 @@ export class CacheService { await this.set(key, fresh, ttlSeconds); return fresh; } + + private async scanPattern( + pattern: string, + onKeys: (keys: string[]) => Promise | void + ): Promise { + let cursor = "0"; + do { + const [next, keys] = (await this.redis.scan( + cursor, + "MATCH", + pattern, + "COUNT", + 1000 + )) as unknown as [string, string[]]; + cursor = next; + if (keys && keys.length) { + await onKeys(keys); + } + } while (cursor !== "0"); + } } diff --git a/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts b/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts index 258fd0f7..45dec883 100644 --- a/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts +++ b/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts @@ -83,14 +83,17 @@ export class MappingCacheService { } } - getStats(): { totalKeys: number; memoryUsage: number } { - let result = { totalKeys: 0, memoryUsage: 0 }; + async getStats(): Promise<{ totalKeys: number; memoryUsage: number }> { + const pattern = `${this.CACHE_PREFIX}:*`; try { - result = { totalKeys: 0, memoryUsage: 0 }; + const totalKeys = await this.cacheService.countByPattern(pattern); + const memoryUsage = + totalKeys === 0 ? 0 : await this.cacheService.memoryUsageByPattern(pattern); + return { totalKeys, memoryUsage }; } catch (error) { this.logger.error("Failed to get cache stats", { error: getErrorMessage(error) }); + return { totalKeys: 0, memoryUsage: 0 }; } - return result; } private async get(key: string): Promise { diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index d5c646d8..a9bff329 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -186,13 +186,6 @@ export class SubscriptionsService { cancelled: number; }> { try { - // Get WHMCS client ID from user mapping - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.whmcsClientId) { - throw new NotFoundException("WHMCS client mapping not found"); - } - - // Get all subscriptions and aggregate customer-facing stats only const subscriptionList = await this.getSubscriptions(userId); const subscriptions: Subscription[] = subscriptionList.subscriptions; @@ -282,7 +275,7 @@ export class SubscriptionsService { async searchSubscriptions(userId: string, query: string): Promise { try { if (!query || query.trim().length < 2) { - throw new Error("Search query must be at least 2 characters long"); + throw new BadRequestException("Search query must be at least 2 characters long"); } const subscriptionList = await this.getSubscriptions(userId); @@ -318,6 +311,7 @@ export class SubscriptionsService { options: { page?: number; limit?: number } = {} ): Promise { const { page = 1, limit = 10 } = options; + const batchSize = Math.min(100, Math.max(limit, 25)); try { // Validate subscription exists and belongs to user @@ -329,51 +323,35 @@ export class SubscriptionsService { throw new NotFoundException("WHMCS client mapping not found"); } - // Get all invoices for the user WITH ITEMS (needed for subscription linking) - // Note: Server-side filtering is handled by the WHMCS service via status and date filters. - // Further performance optimization may involve pagination or selective field retrieval. - const invoicesResponse = await this.whmcsService.getInvoicesWithItems( - mapping.whmcsClientId, - userId, - { page: 1, limit: 1000 } // Get more to filter locally - ); + const relatedInvoices: Invoice[] = []; + let currentPage = 1; + let totalPages = 1; - const allInvoices = invoicesResponse; + do { + const invoiceBatch = await this.whmcsService.getInvoicesWithItems( + mapping.whmcsClientId, + userId, + { page: currentPage, limit: batchSize } + ); - // Filter invoices that have items related to this subscription - // Note: subscriptionId is the same as serviceId in our current WHMCS mapping - this.logger.debug( - `Filtering ${allInvoices.invoices.length} invoices for subscription ${subscriptionId}`, - { - totalInvoices: allInvoices.invoices.length, - invoicesWithItems: allInvoices.invoices.filter( - (inv: Invoice) => inv.items && inv.items.length > 0 - ).length, - subscriptionId, - } - ); + totalPages = invoiceBatch.pagination.totalPages; - const relatedInvoices = allInvoices.invoices.filter((invoice: Invoice) => { - const hasItems = invoice.items && invoice.items.length > 0; - if (!hasItems) { - this.logger.debug(`Invoice ${invoice.id} has no items`); - return false; - } + invoiceBatch.invoices.forEach(invoice => { + if (!invoice.items?.length) { + return; + } - const hasMatchingService = invoice.items?.some((item: InvoiceItem) => { - this.logger.debug( - `Checking item: serviceId=${item.serviceId}, subscriptionId=${subscriptionId}`, - { - itemServiceId: item.serviceId, - subscriptionId, - matches: item.serviceId === subscriptionId, - } + const hasMatchingService = invoice.items.some( + (item: InvoiceItem) => item.serviceId === subscriptionId ); - return item.serviceId === subscriptionId; + + if (hasMatchingService) { + relatedInvoices.push(invoice); + } }); - return hasMatchingService; - }); + currentPage += 1; + } while (currentPage <= totalPages); // Apply pagination to filtered results const startIndex = (page - 1) * limit; @@ -384,7 +362,7 @@ export class SubscriptionsService { invoices: paginatedInvoices, pagination: { page, - totalPages: Math.ceil(relatedInvoices.length / limit), + totalPages: relatedInvoices.length === 0 ? 0 : Math.ceil(relatedInvoices.length / limit), totalItems: relatedInvoices.length, }, }; 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 639b6a98..682ab1dd 100644 --- a/apps/bff/src/modules/users/infra/user-profile.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -154,16 +154,7 @@ export class UserProfileService { if (!mapping?.whmcsClientId) { this.logger.warn(`No WHMCS mapping found for user ${userId}`); - let currency = "JPY"; - try { - const profile = await this.getProfile(userId); - currency = profile.currency_code || currency; - } catch (error) { - this.logger.warn("Could not fetch currency from profile", { - userId, - error: getErrorMessage(error), - }); - } + const currency = "JPY"; const summary: DashboardSummary = { stats: { diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts index 50934cc8..17416ae5 100644 --- a/apps/portal/src/features/orders/services/orders.service.ts +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -1,4 +1,5 @@ import { apiClient } from "@/lib/api"; +import { log } from "@/lib/logger"; import { orderDetailsSchema, orderSummarySchema, @@ -14,27 +15,29 @@ async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: st skus: payload.skus, ...(payload.configurations ? { configurations: payload.configurations } : {}), }; - - // Debug logging - console.log("[DEBUG] Creating order with payload:", JSON.stringify(body, null, 2)); - console.log("[DEBUG] Order Type:", body.orderType); - console.log("[DEBUG] SKUs:", body.skus); - console.log("[DEBUG] Configurations:", body.configurations); - + + log.debug("Creating order", { + orderType: body.orderType, + skuCount: body.skus.length, + hasConfigurations: !!body.configurations, + }); + try { const response = await apiClient.POST("/api/orders", { body }); - - console.log("[DEBUG] Response:", response); - + const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>( response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }> ); return { sfOrderId: parsed.data.sfOrderId }; } catch (error) { - console.error("[DEBUG] Order creation failed:", error); - if (error && typeof error === "object" && "body" in error) { - console.error("[DEBUG] Error body:", error.body); - } + log.error( + "Order creation failed", + error instanceof Error ? error : undefined, + { + orderType: body.orderType, + skuCount: body.skus.length, + } + ); throw error; } }