From 2db8bdb8c12e63a3667f654c2a43572a96d15d28 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 5 May 2026 14:08:45 +0545 Subject: [PATCH 1/3] perf(OUT-3545): parallelize home render and batch Copilot price fetch Initial dashboard load was dominated by two waterfalls. (1) Home.tsx awaited token validation, portal connection, sync status, latest sync log (via HTTP self-call) and workspace info strictly in sequence. (2) ProductService.getFlattenProductList fetched prices per-product through a global Bottleneck (4 concurrent / 200ms), so N products meant N throttled round-trips before SettingAccordion could mount. Server render now runs checkPortalConnection, getWorkspaceInfo and SyncLogService.getLatestSyncSuccessLog in parallel after token validation; syncFlag/isEnabled are read from the eager-loaded portalConnection.setting relation instead of a second DB round-trip; the HTTP self-call to /api/quickbooks/syncLog/success is replaced by a direct service call. Add (home)/loading.tsx for segment-level streaming and share it from Main.tsx's client-side loading state. Workspace-wide listPrices (paginated) replaces the per-product loop in getFlattenProductList, dropping calls from 1+N to ~1+ceil(prices/page) and removing the bottleneck dependency on this read path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/action/quickbooks.action.ts | 17 +--- src/app/(home)/Home.tsx | 70 +++++++-------- src/app/(home)/loading.tsx | 9 ++ .../api/quickbooks/product/product.service.ts | 89 ++++++++++++------- src/components/dashboard/Main.tsx | 13 +-- 5 files changed, 99 insertions(+), 99 deletions(-) create mode 100644 src/app/(home)/loading.tsx diff --git a/src/action/quickbooks.action.ts b/src/action/quickbooks.action.ts index b30a444d..4d370e20 100644 --- a/src/action/quickbooks.action.ts +++ b/src/action/quickbooks.action.ts @@ -2,11 +2,7 @@ import { AuthStatus } from '@/app/api/core/types/auth' import { PortalConnectionWithSettingType } from '@/db/schema/qbPortalConnections' -import { QBSettingsSelectSchemaType } from '@/db/schema/qbSettings' -import { - getPortalConnection, - getPortalSettings, -} from '@/db/service/token.service' +import { getPortalConnection } from '@/db/service/token.service' import IntuitAPI from '@/utils/intuitAPI' import CustomLogger from '@/utils/logger' import { @@ -26,17 +22,6 @@ export async function checkPortalConnection( } } -export async function checkSyncStatus(portalId: string): Promise { - try { - const syncedPortal: QBSettingsSelectSchemaType | null = - await getPortalSettings(portalId) - return syncedPortal?.syncFlag || false - } catch (err) { - console.error('checkSyncStatus#getPortalSettings | Error =', err) - return false - } -} - export async function checkForNonUsCompany(portalId: string): Promise { CustomLogger.info({ message: 'checkForNonUsCompany | Checking for non-US company', diff --git a/src/app/(home)/Home.tsx b/src/app/(home)/Home.tsx index ff33293c..1e8539ac 100644 --- a/src/app/(home)/Home.tsx +++ b/src/app/(home)/Home.tsx @@ -1,23 +1,16 @@ import { getTokenPayload } from '@/action/copilot.action' import { checkPortalConnection, - checkSyncStatus, reconnectIfCta, } from '@/action/quickbooks.action' import HomeClient from '@/app/(home)/HomeClient' +import User from '@/app/api/core/models/User.model' +import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service' import { AppProvider } from '@/app/context/AppContext' import { SilentError } from '@/components/template/SilentError' -import { apiUrl } from '@/config' import { getWorkspaceInfo } from '@/db/service/token.service' import { z } from 'zod' -export async function getLatestSuccesLog(token: string) { - const response = await fetch( - `${apiUrl}/api/quickbooks/syncLog/success?token=${token}`, - ) - return (await response.json()).data -} - export default async function Main({ searchParams, }: { @@ -44,39 +37,38 @@ export default async function Main({ return } - const portalConnection = await checkPortalConnection(tokenPayload.workspaceId) - const portalConnectionStatus = - portalConnection && Object.keys(portalConnection).length > 0 ? true : false + const syncLogService = new SyncLogService(new User(token, tokenPayload)) - let reconnect = false, - syncFlag = false, - successLog = null, - isEnabled = false - if (portalConnectionStatus) { - syncFlag = await checkSyncStatus(tokenPayload.workspaceId) - isEnabled = portalConnection?.setting?.isEnabled || false + const [portalConnection, workspace, latestSuccessLog] = await Promise.all([ + checkPortalConnection(tokenPayload.workspaceId), + getWorkspaceInfo(token), + syncLogService.getLatestSyncSuccessLog(), + ]) - if (!syncFlag) { - reconnect = await reconnectIfCta(type) - } else { - successLog = await getLatestSuccesLog(token) - } - } + const portalConnectionStatus = !!( + portalConnection && Object.keys(portalConnection).length + ) + const syncFlag = portalConnection?.setting?.syncFlag ?? false + const isEnabled = portalConnection?.setting?.isEnabled ?? false + const reconnect = + portalConnectionStatus && !syncFlag ? await reconnectIfCta(type) : false + const lastSyncTimestamp = + portalConnectionStatus && syncFlag + ? (latestSuccessLog?.updatedAt?.toISOString() ?? null) + : null return ( - <> - - - - + + + ) } diff --git a/src/app/(home)/loading.tsx b/src/app/(home)/loading.tsx new file mode 100644 index 00000000..e1616d82 --- /dev/null +++ b/src/app/(home)/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from 'copilot-design-system' + +export default function Loading() { + return ( +
+ +
+ ) +} diff --git a/src/app/api/quickbooks/product/product.service.ts b/src/app/api/quickbooks/product/product.service.ts index c4f1ea7c..82b50cf2 100644 --- a/src/app/api/quickbooks/product/product.service.ts +++ b/src/app/api/quickbooks/product/product.service.ts @@ -12,9 +12,8 @@ import { ProductChangedItemReferenceType, ProductMappingSchemaType, } from '@/db/schema/qbProductSync' -import { ProductResponse, WhereClause } from '@/type/common' +import { PriceResponse, WhereClause } from '@/type/common' import { ProductFlattenArrayResponseType } from '@/type/dto/api.dto' -import { bottleneck } from '@/utils/bottleneck' import { QBItemFullUpdatePayloadType } from '@/type/dto/intuitAPI.dto' import { PriceCreatedResponseType, @@ -41,6 +40,7 @@ import { } from '@/utils/string' import { AccountTypeObj } from '@/constant/qbConnection' import { TokenService } from '@/app/api/quickbooks/token/token.service' +import { MAX_PRODUCT_LIST_LIMIT } from '@/app/api/core/constants/limit' export type ProductSyncTokenResponse = { id: string @@ -319,47 +319,68 @@ export class ProductService extends BaseService { return await intuitApi.createItem(qbItemPayload) } - async getFlatMapforAProduct(product: ProductResponse, copilot: CopilotAPI) { - const prices = await copilot.getPrices(product.id) - return (prices?.data ?? []) - .map((price) => ({ - ...product, - description: convert(product.description), - priceId: price.id, - amount: price.amount, - type: price.type, - interval: price.interval, - intervalCount: price.intervalCount, - currency: price.currency, - })) - .sort((a, b) => a.amount - b.amount) // sort by amount in asc order - } - async getFlattenProductList( limit: number, nextToken?: string, ): Promise { - // get all the products from copilot const copilot = new CopilotAPI(this.user.token) - const products = await copilot.getProducts(undefined, nextToken, limit) - let flattenProductsPrice: ProductFlattenArrayResponseType = { - products: [], - } - const flatmapProductPrice = [] - if (products?.data) { - for (const product of products.data) { - flatmapProductPrice.push( - bottleneck.schedule(() => { - return this.getFlatMapforAProduct(product, copilot) - }), + + const [products, pricesByProduct] = await Promise.all([ + copilot.getProducts(undefined, nextToken, limit), + this.fetchAllPricesGroupedByProduct(copilot), + ]) + + const flattened = (products?.data ?? []).flatMap((product) => { + const prices = pricesByProduct.get(product.id) ?? [] + const productDescription = convert(product.description) + return prices + .map((price) => ({ + ...product, + description: productDescription, + priceId: price.id, + amount: price.amount, + type: price.type, + interval: price.interval, + intervalCount: price.intervalCount, + currency: price.currency, + })) + .sort((a, b) => a.amount - b.amount) // sort by amount in asc order + }) + + return { products: flattened } + } + + private async fetchAllPricesGroupedByProduct( + copilot: CopilotAPI, + ): Promise> { + const grouped = new Map() + let nextToken: string | undefined + do { + const page = await copilot.getPrices( + undefined, + nextToken, + MAX_PRODUCT_LIST_LIMIT.toString(), + ) + if (!page) { + // Transient SDK failure: bail rather than silently dropping every + // product on the page from the flattened response. + console.warn( + 'fetchAllPricesGroupedByProduct | getPrices returned undefined; aborting pagination', ) + break } - flattenProductsPrice = { - products: (await Promise.all(flatmapProductPrice)).flat(), + for (const price of page.data ?? []) { + const list = grouped.get(price.productId) + if (list) { + list.push(price) + } else { + grouped.set(price.productId, [price]) + } } - } + nextToken = page.nextToken + } while (nextToken) - return flattenProductsPrice + return grouped } /** diff --git a/src/components/dashboard/Main.tsx b/src/components/dashboard/Main.tsx index 67923e74..feab7fb4 100644 --- a/src/components/dashboard/Main.tsx +++ b/src/components/dashboard/Main.tsx @@ -1,16 +1,11 @@ 'use client' +import Loading from '@/app/(home)/loading' import SettingAccordion from '@/components/dashboard/settings/SettingAccordion' import { CalloutVariant } from '@/components/type/callout' import Divider from '@/components/ui/Divider' import { useDashboardMain } from '@/hook/useDashboard' -import { - ButtonProps, - Callout, - Heading, - IconType, - Spinner, -} from 'copilot-design-system' +import { ButtonProps, Callout, Heading, IconType } from 'copilot-design-system' import LastSyncAt from '@/components/dashboard/LastSyncAt' import { SilentError } from '@/components/template/SilentError' import { useApp } from '@/app/context/AppContext' @@ -85,9 +80,7 @@ export const Main = () => { return ( <> {isLoading ? ( -
- -
+ ) : (
{nonUsCompany && ( From 79c643104a3fcb1f5bc62c2145bba638f4316e9f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 5 May 2026 16:01:41 +0545 Subject: [PATCH 2/3] fix(OUT-3545): preserve home render on sync-log fault, drop unused product pagination knobs Home.tsx: catch errors from getLatestSyncSuccessLog inside Promise.all so a transient DB failure nulls the timestamp instead of crashing the render (matches the prior HTTP path's withErrorHandler swallowing). Product flatten endpoint: remove the limit/nextToken query params (only consumer is the SWR hook, which never passed them and the response DTO never exposed nextToken). Service now hardcodes MAX_PRODUCT_LIST_LIMIT for the single-page workload it actually serves, and the workspace-wide price walk is documented. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(home)/Home.tsx | 5 ++++- .../api/quickbooks/product/product.controller.ts | 5 +---- src/app/api/quickbooks/product/product.service.ts | 15 ++++++++++----- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/app/(home)/Home.tsx b/src/app/(home)/Home.tsx index 1e8539ac..65863af8 100644 --- a/src/app/(home)/Home.tsx +++ b/src/app/(home)/Home.tsx @@ -42,7 +42,10 @@ export default async function Main({ const [portalConnection, workspace, latestSuccessLog] = await Promise.all([ checkPortalConnection(tokenPayload.workspaceId), getWorkspaceInfo(token), - syncLogService.getLatestSyncSuccessLog(), + syncLogService.getLatestSyncSuccessLog().catch((err) => { + console.error('Home#getLatestSyncSuccessLog | Error =', err) + return null + }), ]) const portalConnectionStatus = !!( diff --git a/src/app/api/quickbooks/product/product.controller.ts b/src/app/api/quickbooks/product/product.controller.ts index 1d797e19..b3a9e124 100644 --- a/src/app/api/quickbooks/product/product.controller.ts +++ b/src/app/api/quickbooks/product/product.controller.ts @@ -8,10 +8,7 @@ import { NextRequest, NextResponse } from 'next/server' export async function getFlattenProducts(req: NextRequest) { const user = await authenticate(req) const productService = new ProductService(user) - const searchParams = req.nextUrl.searchParams - const nextToken = searchParams.get('nextToken') || undefined - const limit = Number(searchParams.get('limit')) || MAX_PRODUCT_LIST_LIMIT - const products = await productService.getFlattenProductList(limit, nextToken) + const products = await productService.getFlattenProductList() return NextResponse.json(products) } diff --git a/src/app/api/quickbooks/product/product.service.ts b/src/app/api/quickbooks/product/product.service.ts index 82b50cf2..2e252f58 100644 --- a/src/app/api/quickbooks/product/product.service.ts +++ b/src/app/api/quickbooks/product/product.service.ts @@ -319,14 +319,11 @@ export class ProductService extends BaseService { return await intuitApi.createItem(qbItemPayload) } - async getFlattenProductList( - limit: number, - nextToken?: string, - ): Promise { + async getFlattenProductList(): Promise { const copilot = new CopilotAPI(this.user.token) const [products, pricesByProduct] = await Promise.all([ - copilot.getProducts(undefined, nextToken, limit), + copilot.getProducts(undefined, undefined, MAX_PRODUCT_LIST_LIMIT), this.fetchAllPricesGroupedByProduct(copilot), ]) @@ -350,6 +347,14 @@ export class ProductService extends BaseService { return { products: flattened } } + /** + * Walks every page of the workspace's /prices endpoint and groups by + * productId. Replaces the prior bottleneck-throttled N+1 per-product fetch + * with ceil(totalPrices / MAX_PRODUCT_LIST_LIMIT) sequential calls, which is + * dramatically faster for the single-page workload getFlattenProductList + * actually serves. If product pagination is ever reintroduced, revisit: + * caller would repeat this full walk per page with no cross-call cache. + */ private async fetchAllPricesGroupedByProduct( copilot: CopilotAPI, ): Promise> { From 550b9f5fca2afbfd59ee3e578b549ee300c4d597 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 6 May 2026 16:33:37 +0545 Subject: [PATCH 3/3] refactor(OUT-3545): use meaningful function name --- src/app/api/quickbooks/product/flatten/route.ts | 4 ++-- src/app/api/quickbooks/product/product.controller.ts | 4 ++-- src/app/api/quickbooks/product/product.service.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/api/quickbooks/product/flatten/route.ts b/src/app/api/quickbooks/product/flatten/route.ts index a3e54dac..d5612e17 100644 --- a/src/app/api/quickbooks/product/flatten/route.ts +++ b/src/app/api/quickbooks/product/flatten/route.ts @@ -1,6 +1,6 @@ import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' -import { getFlattenProducts } from '@/app/api/quickbooks/product/product.controller' +import { getProductsWithPrices } from '@/app/api/quickbooks/product/product.controller' export const maxDuration = 300 // 5 minutes -export const GET = withErrorHandler(getFlattenProducts) +export const GET = withErrorHandler(getProductsWithPrices) diff --git a/src/app/api/quickbooks/product/product.controller.ts b/src/app/api/quickbooks/product/product.controller.ts index b3a9e124..a62254d9 100644 --- a/src/app/api/quickbooks/product/product.controller.ts +++ b/src/app/api/quickbooks/product/product.controller.ts @@ -5,10 +5,10 @@ import { ProductService } from '@/app/api/quickbooks/product/product.service' import { ProductMappingSchema } from '@/db/schema/qbProductSync' import { NextRequest, NextResponse } from 'next/server' -export async function getFlattenProducts(req: NextRequest) { +export async function getProductsWithPrices(req: NextRequest) { const user = await authenticate(req) const productService = new ProductService(user) - const products = await productService.getFlattenProductList() + const products = await productService.getProductsWithPrices() return NextResponse.json(products) } diff --git a/src/app/api/quickbooks/product/product.service.ts b/src/app/api/quickbooks/product/product.service.ts index 2e252f58..ab3e29ca 100644 --- a/src/app/api/quickbooks/product/product.service.ts +++ b/src/app/api/quickbooks/product/product.service.ts @@ -319,7 +319,7 @@ export class ProductService extends BaseService { return await intuitApi.createItem(qbItemPayload) } - async getFlattenProductList(): Promise { + async getProductsWithPrices(): Promise { const copilot = new CopilotAPI(this.user.token) const [products, pricesByProduct] = await Promise.all([ @@ -351,7 +351,7 @@ export class ProductService extends BaseService { * Walks every page of the workspace's /prices endpoint and groups by * productId. Replaces the prior bottleneck-throttled N+1 per-product fetch * with ceil(totalPrices / MAX_PRODUCT_LIST_LIMIT) sequential calls, which is - * dramatically faster for the single-page workload getFlattenProductList + * dramatically faster for the single-page workload getProductsWithPrices * actually serves. If product pagination is ever reintroduced, revisit: * caller would repeat this full walk per page with no cross-call cache. */