From e449b467840d4737301346e14a1a33c07ff9e519 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 18 Aug 2026 21:38:02 +0545 Subject: [PATCH 01/11] chore(OUT-4067): swap copilot-node-sdk for @assembly-js/node-sdk Co-Authored-By: Claude Opus 4.8 --- package.json | 4 +- yarn.lock | 206 +++------------------------------------------------ 2 files changed, 12 insertions(+), 198 deletions(-) diff --git a/package.json b/package.json index 21f3db10..b85aa548 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,12 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@assembly-js/node-sdk": "^4.2.2", "@sentry/nextjs": "^9.13.0", "@supabase/supabase-js": "^2.49.4", "@trigger.dev/sdk": "4.4.4", "bottleneck": "^2.19.5", "copilot-design-system": "^2.0.10", - "copilot-node-sdk": "~3.16.0", "dayjs": "^1.11.13", "deep-equal": "^2.2.3", "drizzle-orm": "^0.42.0", @@ -103,4 +103,4 @@ "prettier --write" ] } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 0953a0ca..df0ac9c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29,6 +29,13 @@ __metadata: languageName: node linkType: hard +"@assembly-js/node-sdk@npm:^4.2.2": + version: 4.2.2 + resolution: "@assembly-js/node-sdk@npm:4.2.2" + checksum: 10c0/e2f84680e5f5e7cbec5f95b2a17119896ce53f2db8b957a575dc57164826fd182105962383f9572435538c73e9f895b8df261c02b616c32171529a45fff95963 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -1730,13 +1737,6 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:14.1.3": - version: 14.1.3 - resolution: "@next/env@npm:14.1.3" - checksum: 10c0/928dba385f1ed3346880845662f87fe386e42ee0c798ccbd7c13a31e2cb509153d42e6c8a4a73ad9c6d647ac9cb6a570316c8ddcaccc9ec201f7519ec383dc02 - languageName: node - linkType: hard - "@next/env@npm:15.5.18": version: 15.5.18 resolution: "@next/env@npm:15.5.18" @@ -1753,13 +1753,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:14.1.3": - version: 14.1.3 - resolution: "@next/swc-darwin-arm64@npm:14.1.3" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@next/swc-darwin-arm64@npm:15.5.18": version: 15.5.18 resolution: "@next/swc-darwin-arm64@npm:15.5.18" @@ -1767,13 +1760,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-darwin-x64@npm:14.1.3": - version: 14.1.3 - resolution: "@next/swc-darwin-x64@npm:14.1.3" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@next/swc-darwin-x64@npm:15.5.18": version: 15.5.18 resolution: "@next/swc-darwin-x64@npm:15.5.18" @@ -1781,13 +1767,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:14.1.3": - version: 14.1.3 - resolution: "@next/swc-linux-arm64-gnu@npm:14.1.3" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "@next/swc-linux-arm64-gnu@npm:15.5.18": version: 15.5.18 resolution: "@next/swc-linux-arm64-gnu@npm:15.5.18" @@ -1795,13 +1774,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:14.1.3": - version: 14.1.3 - resolution: "@next/swc-linux-arm64-musl@npm:14.1.3" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - "@next/swc-linux-arm64-musl@npm:15.5.18": version: 15.5.18 resolution: "@next/swc-linux-arm64-musl@npm:15.5.18" @@ -1809,13 +1781,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:14.1.3": - version: 14.1.3 - resolution: "@next/swc-linux-x64-gnu@npm:14.1.3" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "@next/swc-linux-x64-gnu@npm:15.5.18": version: 15.5.18 resolution: "@next/swc-linux-x64-gnu@npm:15.5.18" @@ -1823,13 +1788,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:14.1.3": - version: 14.1.3 - resolution: "@next/swc-linux-x64-musl@npm:14.1.3" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "@next/swc-linux-x64-musl@npm:15.5.18": version: 15.5.18 resolution: "@next/swc-linux-x64-musl@npm:15.5.18" @@ -1837,13 +1795,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:14.1.3": - version: 14.1.3 - resolution: "@next/swc-win32-arm64-msvc@npm:14.1.3" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@next/swc-win32-arm64-msvc@npm:15.5.18": version: 15.5.18 resolution: "@next/swc-win32-arm64-msvc@npm:15.5.18" @@ -1851,20 +1802,6 @@ __metadata: languageName: node linkType: hard -"@next/swc-win32-ia32-msvc@npm:14.1.3": - version: 14.1.3 - resolution: "@next/swc-win32-ia32-msvc@npm:14.1.3" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - -"@next/swc-win32-x64-msvc@npm:14.1.3": - version: 14.1.3 - resolution: "@next/swc-win32-x64-msvc@npm:14.1.3" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@next/swc-win32-x64-msvc@npm:15.5.18": version: 15.5.18 resolution: "@next/swc-win32-x64-msvc@npm:15.5.18" @@ -3935,15 +3872,6 @@ __metadata: languageName: node linkType: hard -"@swc/helpers@npm:0.5.2": - version: 0.5.2 - resolution: "@swc/helpers@npm:0.5.2" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10c0/b6fa49bcf6c00571d0eb7837b163f8609960d4d77538160585e27ed167361e9776bd6e5eb9646ffac2fb4d43c58df9ca50dab9d96ab097e6591bc82a75fd1164 - languageName: node - linkType: hard - "@tailwindcss/node@npm:4.1.5": version: 4.1.5 resolution: "@tailwindcss/node@npm:4.1.5" @@ -5570,15 +5498,6 @@ __metadata: languageName: node linkType: hard -"busboy@npm:1.6.0": - version: 1.6.0 - resolution: "busboy@npm:1.6.0" - dependencies: - streamsearch: "npm:^1.1.0" - checksum: 10c0/fa7e836a2b82699b6e074393428b91ae579d4f9e21f5ac468e1b459a244341d722d2d22d10920cdd849743dbece6dca11d72de939fb75a7448825cf2babfba1f - languageName: node - linkType: hard - "byline@npm:^5.0.0": version: 5.0.0 resolution: "byline@npm:5.0.0" @@ -6075,16 +5994,6 @@ __metadata: languageName: node linkType: hard -"copilot-node-sdk@npm:~3.16.0": - version: 3.16.0 - resolution: "copilot-node-sdk@npm:3.16.0" - dependencies: - isomorphic-fetch: "npm:^3.0.0" - next: "npm:^14.0.2" - checksum: 10c0/6ac9cbdf0c61d78bfb758f13eca7fc9b290e1d48d32acf22499a3d196f650acc1f1f022c61eac30848acb63db052ed03e29210fb0f4f9309676bebb4c8d604c4 - languageName: node - linkType: hard - "core-js@npm:^3.49.0": version: 3.49.0 resolution: "core-js@npm:3.49.0" @@ -6223,6 +6132,7 @@ __metadata: version: 0.0.0-use.local resolution: "custom-app-base@workspace:." dependencies: + "@assembly-js/node-sdk": "npm:^4.2.2" "@eslint/eslintrc": "npm:^3.3.1" "@eslint/js": "npm:^9.24.0" "@ngrok/ngrok": "npm:^1.4.1" @@ -6243,7 +6153,6 @@ __metadata: autoprefixer: "npm:^10.4.0" bottleneck: "npm:^2.19.5" copilot-design-system: "npm:^2.0.10" - copilot-node-sdk: "npm:~3.16.0" dayjs: "npm:^1.11.13" deep-equal: "npm:^2.2.3" dotenv: "npm:^16.4.5" @@ -8648,7 +8557,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -9524,16 +9433,6 @@ __metadata: languageName: node linkType: hard -"isomorphic-fetch@npm:^3.0.0": - version: 3.0.0 - resolution: "isomorphic-fetch@npm:3.0.0" - dependencies: - node-fetch: "npm:^2.6.1" - whatwg-fetch: "npm:^3.4.1" - checksum: 10c0/511b1135c6d18125a07de661091f5e7403b7640060355d2d704ce081e019bc1862da849482d079ce5e2559b8976d3de7709566063aec1b908369c0b98a2b075b - languageName: node - linkType: hard - "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" @@ -10778,61 +10677,6 @@ __metadata: languageName: node linkType: hard -"next@npm:^14.0.2": - version: 14.1.3 - resolution: "next@npm:14.1.3" - dependencies: - "@next/env": "npm:14.1.3" - "@next/swc-darwin-arm64": "npm:14.1.3" - "@next/swc-darwin-x64": "npm:14.1.3" - "@next/swc-linux-arm64-gnu": "npm:14.1.3" - "@next/swc-linux-arm64-musl": "npm:14.1.3" - "@next/swc-linux-x64-gnu": "npm:14.1.3" - "@next/swc-linux-x64-musl": "npm:14.1.3" - "@next/swc-win32-arm64-msvc": "npm:14.1.3" - "@next/swc-win32-ia32-msvc": "npm:14.1.3" - "@next/swc-win32-x64-msvc": "npm:14.1.3" - "@swc/helpers": "npm:0.5.2" - busboy: "npm:1.6.0" - caniuse-lite: "npm:^1.0.30001579" - graceful-fs: "npm:^4.2.11" - postcss: "npm:8.4.31" - styled-jsx: "npm:5.1.1" - peerDependencies: - "@opentelemetry/api": ^1.1.0 - react: ^18.2.0 - react-dom: ^18.2.0 - sass: ^1.3.0 - dependenciesMeta: - "@next/swc-darwin-arm64": - optional: true - "@next/swc-darwin-x64": - optional: true - "@next/swc-linux-arm64-gnu": - optional: true - "@next/swc-linux-arm64-musl": - optional: true - "@next/swc-linux-x64-gnu": - optional: true - "@next/swc-linux-x64-musl": - optional: true - "@next/swc-win32-arm64-msvc": - optional: true - "@next/swc-win32-ia32-msvc": - optional: true - "@next/swc-win32-x64-msvc": - optional: true - peerDependenciesMeta: - "@opentelemetry/api": - optional: true - sass: - optional: true - bin: - next: dist/bin/next - checksum: 10c0/b723955669b40b49761220b582e46ee0fb472b01b67fb9b6ceabc9a191a252bba253a65d72b284fcac1001c85d39bd6816db71e78e97b2221137ed15776c35bd - languageName: node - linkType: hard - "node-domexception@npm:^1.0.0": version: 1.0.0 resolution: "node-domexception@npm:1.0.0" @@ -10847,7 +10691,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.7": +"node-fetch@npm:^2.6.7": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -13113,13 +12957,6 @@ __metadata: languageName: node linkType: hard -"streamsearch@npm:^1.1.0": - version: 1.1.0 - resolution: "streamsearch@npm:1.1.0" - checksum: 10c0/fbd9aecc2621364384d157f7e59426f4bfd385e8b424b5aaa79c83a6f5a1c8fd2e4e3289e95de1eb3511cb96bb333d6281a9919fafce760e4edb35b2cd2facab - languageName: node - linkType: hard - "streamx@npm:^2.12.5, streamx@npm:^2.15.0, streamx@npm:^2.25.0": version: 2.25.0 resolution: "streamx@npm:2.25.0" @@ -13369,22 +13206,6 @@ __metadata: languageName: node linkType: hard -"styled-jsx@npm:5.1.1": - version: 5.1.1 - resolution: "styled-jsx@npm:5.1.1" - dependencies: - client-only: "npm:0.0.1" - peerDependencies: - react: ">= 16.8.0 || 17.x.x || ^18.0.0-0" - peerDependenciesMeta: - "@babel/core": - optional: true - babel-plugin-macros: - optional: true - checksum: 10c0/42655cdadfa5388f8a48bb282d6b450df7d7b8cf066ac37038bd0499d3c9f084815ebd9ff9dfa12a218fd4441338851db79603498d7557207009c1cf4d609835 - languageName: node - linkType: hard - "styled-jsx@npm:5.1.6": version: 5.1.6 resolution: "styled-jsx@npm:5.1.6" @@ -14342,13 +14163,6 @@ __metadata: languageName: node linkType: hard -"whatwg-fetch@npm:^3.4.1": - version: 3.6.20 - resolution: "whatwg-fetch@npm:3.6.20" - checksum: 10c0/fa972dd14091321d38f36a4d062298df58c2248393ef9e8b154493c347c62e2756e25be29c16277396046d6eaa4b11bd174f34e6403fff6aaca9fb30fa1ff46d - languageName: node - linkType: hard - "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" From 584f161e219dd41450f82f503126f60c9cb3b426 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 18 Aug 2026 21:38:03 +0545 Subject: [PATCH 02/11] refactor(OUT-4067): rename CopilotAPI to AssemblyAPI and scope the client by workspaceId Co-Authored-By: Claude Opus 4.8 --- src/app/(home)/Home.tsx | 2 +- src/app/api/core/utils/withErrorHandler.ts | 2 +- src/app/api/core/utils/withRetry.ts | 2 +- .../api/notification/notification.service.ts | 6 +- .../quickbooks/customer/customer.service.ts | 4 +- .../api/quickbooks/invoice/invoice.service.ts | 6 +- .../api/quickbooks/product/product.service.ts | 4 +- src/app/api/quickbooks/sync/sync.service.ts | 10 +- .../api/quickbooks/webhook/webhook.service.ts | 4 +- .../backfillProductInfo.service.ts | 4 +- .../syncMissedInvoices.service.ts | 4 +- .../syncMissedProducts.service.ts | 4 +- src/db/service/token.service.ts | 6 +- src/helper/fetch.helper.ts | 2 +- src/utils/{copilotAPI.ts => assemblyAPI.ts} | 229 +++++++----------- 15 files changed, 117 insertions(+), 172 deletions(-) rename src/utils/{copilotAPI.ts => assemblyAPI.ts} (62%) diff --git a/src/app/(home)/Home.tsx b/src/app/(home)/Home.tsx index 65863af8..e77ecfef 100644 --- a/src/app/(home)/Home.tsx +++ b/src/app/(home)/Home.tsx @@ -41,7 +41,7 @@ export default async function Main({ const [portalConnection, workspace, latestSuccessLog] = await Promise.all([ checkPortalConnection(tokenPayload.workspaceId), - getWorkspaceInfo(token), + getWorkspaceInfo(tokenPayload.workspaceId), syncLogService.getLatestSyncSuccessLog().catch((err) => { console.error('Home#getLatestSyncSuccessLog | Error =', err) return null diff --git a/src/app/api/core/utils/withErrorHandler.ts b/src/app/api/core/utils/withErrorHandler.ts index 472262af..61bb6fe3 100644 --- a/src/app/api/core/utils/withErrorHandler.ts +++ b/src/app/api/core/utils/withErrorHandler.ts @@ -37,7 +37,7 @@ type RequestHandler = (req: NextRequest, params: any) => Promise * }); * * @throws {ZodError} Captures and handles validation errors and responds with status 400 and the issue detail. - * @throws {CopilotApiError} Captures and handles CopilotAPI errors, uses the error status, and message if available. + * @throws {CopilotApiError} Captures and handles AssemblyAPI errors, uses the error status, and message if available. * @throws {APIError} Captures and handles APIError * @throws {AxiosError} Captures and handles AxiosError (Specially from Intuit SDK) */ diff --git a/src/app/api/core/utils/withRetry.ts b/src/app/api/core/utils/withRetry.ts index 65ce290b..e91998d8 100644 --- a/src/app/api/core/utils/withRetry.ts +++ b/src/app/api/core/utils/withRetry.ts @@ -159,7 +159,7 @@ export const withRetry = async ( factor: 4, onFailedAttempt: (error: FailedAttemptError) => { console.warn( - `CopilotAPI#withRetry | Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} retries left. Error:`, + `AssemblyAPI#withRetry | Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} retries left. Error:`, error, ) }, diff --git a/src/app/api/notification/notification.service.ts b/src/app/api/notification/notification.service.ts index adb51974..98c965c5 100644 --- a/src/app/api/notification/notification.service.ts +++ b/src/app/api/notification/notification.service.ts @@ -9,7 +9,7 @@ import { getInProductNotificationDetail, } from '@/app/api/notification/notification.helper' import { InternalUsersResponse } from '@/type/common' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyAPI } from '@/utils/assemblyAPI' import CustomLogger from '@/utils/logger' import { captureException, captureMessage } from '@sentry/nextjs' @@ -41,7 +41,7 @@ export class NotificationService extends BaseService { this.user.token, ) try { - const copilot = new CopilotAPI(this.user.token) + const copilot = new AssemblyAPI(this.user.workspaceId) // 1. get all parties that gets notification const parties = await this.getAllParties(copilot, action) @@ -102,7 +102,7 @@ export class NotificationService extends BaseService { } async getAllParties( - copilot: CopilotAPI, + copilot: AssemblyAPI, action: NotificationActions, ): Promise { if (IU_RECIPIENT_ACTIONS.has(action)) { diff --git a/src/app/api/quickbooks/customer/customer.service.ts b/src/app/api/quickbooks/customer/customer.service.ts index 4ab6d92f..1cebfc31 100644 --- a/src/app/api/quickbooks/customer/customer.service.ts +++ b/src/app/api/quickbooks/customer/customer.service.ts @@ -13,7 +13,7 @@ import { import { CompanyResponse, WhereClause } from '@/type/common' import { QBCustomerCreatePayloadType } from '@/type/dto/intuitAPI.dto' import { InvoiceCreatedResponseType } from '@/type/dto/webhook.dto' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyAPI } from '@/utils/assemblyAPI' import IntuitAPI from '@/utils/intuitAPI' import { addSyncBreadcrumb } from '@/utils/sentry' import { replaceSpecialCharsForQB } from '@/utils/string' @@ -145,7 +145,7 @@ export class CustomerService extends BaseService { ) } - const copilot = new CopilotAPI(this.user.token) + const copilot = new AssemblyAPI(this.user.workspaceId) let client // get client and company info from copilot diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index a9ebeaef..13d985f9 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -50,7 +50,7 @@ import { InvoiceResponseType, } from '@/type/dto/webhook.dto' import { bottleneck } from '@/utils/bottleneck' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyAPI } from '@/utils/assemblyAPI' import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' import dayjs from 'dayjs' import { and, eq, isNull } from 'drizzle-orm' @@ -74,12 +74,12 @@ type InvoiceItemRefAndDescriptionType = { } export class InvoiceService extends BaseService { - private copilot: CopilotAPI + private copilot: AssemblyAPI private syncLogService: SyncLogService constructor(user: User) { super(user) - this.copilot = new CopilotAPI(user.token) + this.copilot = new AssemblyAPI(user.workspaceId) this.syncLogService = new SyncLogService(user) } diff --git a/src/app/api/quickbooks/product/product.service.ts b/src/app/api/quickbooks/product/product.service.ts index 7f4c0854..cf191e9b 100644 --- a/src/app/api/quickbooks/product/product.service.ts +++ b/src/app/api/quickbooks/product/product.service.ts @@ -19,7 +19,7 @@ import { ProductCreatedResponseType, ProductUpdatedResponseType, } from '@/type/dto/webhook.dto' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyAPI } from '@/utils/assemblyAPI' import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' import { and, @@ -327,7 +327,7 @@ export class ProductService extends BaseService { * their own UnitPrice, so the table is product-to-item only. */ async getProductsForMapping(): Promise { - const copilot = new CopilotAPI(this.user.token) + const copilot = new AssemblyAPI(this.user.workspaceId) const products = await copilot.getProducts({ limit: MAX_PRODUCT_LIST_LIMIT, }) diff --git a/src/app/api/quickbooks/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index efd93239..9e6129c9 100644 --- a/src/app/api/quickbooks/sync/sync.service.ts +++ b/src/app/api/quickbooks/sync/sync.service.ts @@ -3,7 +3,7 @@ import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service' import { InvoiceService } from '@/app/api/quickbooks/invoice/invoice.service' import { AuthService } from '@/app/api/quickbooks/auth/auth.service' import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyAPI } from '@/utils/assemblyAPI' import { FailedRecordCategoryType, EntityType, @@ -43,7 +43,7 @@ export class SyncService extends BaseService { record: QBSyncLogSelectSchemaType, qbTokenInfo: IntuitAPITokensType, ) { - const copilotApi = new CopilotAPI(this.user.token) + const copilotApi = new AssemblyAPI(this.user.workspaceId) try { // get invoice from Copilot API @@ -74,7 +74,7 @@ export class SyncService extends BaseService { record: QBSyncLogSelectSchemaType, qbTokenInfo: IntuitAPITokensType, ) { - const copilotApi = new CopilotAPI(this.user.token) + const copilotApi = new AssemblyAPI(this.user.workspaceId) try { // get invoice from Copilot API @@ -312,7 +312,7 @@ export class SyncService extends BaseService { qbTokenInfo: IntuitAPITokensType, ) { const productService = new ProductService(this.user) - const copilotApi = new CopilotAPI(this.user.token) + const copilotApi = new AssemblyAPI(this.user.workspaceId) const productResponse = await copilotApi.getProduct( z.string().parse(record.copilotId), @@ -345,7 +345,7 @@ export class SyncService extends BaseService { qbTokenInfo: IntuitAPITokensType, ) { const productService = new ProductService(this.user) - const copilotApi = new CopilotAPI(this.user.token) + const copilotApi = new AssemblyAPI(this.user.workspaceId) try { const product = await copilotApi.getProduct(record.copilotId) diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index 343f56b6..19127f3c 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -20,7 +20,7 @@ import { WebhookEventResponseType, } from '@/type/dto/webhook.dto' import { validateAccessToken } from '@/utils/auth' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyAPI } from '@/utils/assemblyAPI' import { ErrorMessageAndCode, getMessageAndCodeFromError } from '@/utils/error' import { IntuitAPITokensType } from '@/utils/intuitAPI' import CustomLogger from '@/utils/logger' @@ -509,7 +509,7 @@ export class WebhookService extends BaseService { return } - const copilotApp = new CopilotAPI(this.user.token) + const copilotApp = new AssemblyAPI(this.user.workspaceId) const invoice = await copilotApp.getInvoice( parsedPaymentSucceedResource.data.invoiceId, ) diff --git a/src/cmd/backfillProductInfo/backfillProductInfo.service.ts b/src/cmd/backfillProductInfo/backfillProductInfo.service.ts index bda0a91c..3bc567ab 100644 --- a/src/cmd/backfillProductInfo/backfillProductInfo.service.ts +++ b/src/cmd/backfillProductInfo/backfillProductInfo.service.ts @@ -9,7 +9,7 @@ import { QBProductSync, } from '@/db/schema/qbProductSync' import { StatusableError } from '@/type/CopilotApiError' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyAPI } from '@/utils/assemblyAPI' import IntuitAPI from '@/utils/intuitAPI' import { eq, isNotNull } from 'drizzle-orm' import { convert } from 'html-to-text' @@ -38,7 +38,7 @@ export class BackfillProductInfoService extends BaseService { } // 2. get all products from assembly - const copilotApi = new CopilotAPI(this.user.token) + const copilotApi = new AssemblyAPI(this.user.workspaceId) const assemblyProducts = ( await copilotApi.getProducts({ limit: MAX_PRODUCT_LIST_LIMIT }) )?.data diff --git a/src/cmd/syncMissedInvoices/syncMissedInvoices.service.ts b/src/cmd/syncMissedInvoices/syncMissedInvoices.service.ts index 46b6e170..fece8d08 100644 --- a/src/cmd/syncMissedInvoices/syncMissedInvoices.service.ts +++ b/src/cmd/syncMissedInvoices/syncMissedInvoices.service.ts @@ -6,7 +6,7 @@ import { AuthService } from '@/app/api/quickbooks/auth/auth.service' import { InvoiceService } from '@/app/api/quickbooks/invoice/invoice.service' import { QBSyncLog } from '@/db/schema/qbSyncLogs' import { StatusableError } from '@/type/CopilotApiError' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyAPI } from '@/utils/assemblyAPI' import CustomLogger from '@/utils/logger' import { and, eq, gte, or, sql } from 'drizzle-orm' import httpStatus from 'http-status' @@ -32,7 +32,7 @@ export class SyncMissedInvoicesService extends BaseService { ) // 2. Fetch all invoices from Copilot for this portal (single API call) - const copilotApi = new CopilotAPI(this.user.token) + const copilotApi = new AssemblyAPI(this.user.workspaceId) const allInvoices = await copilotApi.getInvoices(this.user.workspaceId) const allPayments = await copilotApi.getPayments() diff --git a/src/cmd/syncMissedProducts/syncMissedProducts.service.ts b/src/cmd/syncMissedProducts/syncMissedProducts.service.ts index e8d0e56a..911bc23c 100644 --- a/src/cmd/syncMissedProducts/syncMissedProducts.service.ts +++ b/src/cmd/syncMissedProducts/syncMissedProducts.service.ts @@ -3,7 +3,7 @@ import { BaseService } from '@/app/api/core/services/base.service' import { withRetry } from '@/app/api/core/utils/withRetry' import { AuthService } from '@/app/api/quickbooks/auth/auth.service' import { StatusableError } from '@/type/CopilotApiError' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyAPI } from '@/utils/assemblyAPI' import { and, eq } from 'drizzle-orm' import httpStatus from 'http-status' import { MAX_PRODUCT_LIST_LIMIT } from '@/app/api/core/constants/limit' @@ -26,7 +26,7 @@ export class SyncMissedProductsService extends BaseService { ) // 1. Get all the products for the portal - const copilotApi = new CopilotAPI(this.user.token) + const copilotApi = new AssemblyAPI(this.user.workspaceId) const allProducts = await copilotApi.getProducts({ limit: MAX_PRODUCT_LIST_LIMIT, }) diff --git a/src/db/service/token.service.ts b/src/db/service/token.service.ts index 38d986f6..cb56b22d 100644 --- a/src/db/service/token.service.ts +++ b/src/db/service/token.service.ts @@ -8,7 +8,7 @@ import { } from '@/db/schema/qbPortalConnections' import { QBSetting, QBSettingsSelectSchemaType } from '@/db/schema/qbSettings' import { WorkspaceResponse } from '@/type/common' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyAPI } from '@/utils/assemblyAPI' import { IntuitAPITokensType } from '@/utils/intuitAPI' import { and, asc, eq, isNotNull, isNull, sql } from 'drizzle-orm' import httpStatus from 'http-status' @@ -101,9 +101,9 @@ export const getPortalSettings = async ( } export const getWorkspaceInfo = async ( - token: string, + workspaceId: string, ): Promise => { - return await new CopilotAPI(token).getWorkspace() + return await new AssemblyAPI(workspaceId).getWorkspace() } export const getPortalTokens = async ( diff --git a/src/helper/fetch.helper.ts b/src/helper/fetch.helper.ts index 9b35cf46..64a3e2c2 100644 --- a/src/helper/fetch.helper.ts +++ b/src/helper/fetch.helper.ts @@ -35,7 +35,7 @@ const extractUpstreamDetail = (body: unknown): string | undefined => { return undefined } -// Exported so other clients with their own fetch wrappers (e.g. CopilotAPI's +// Exported so other clients with their own fetch wrappers (e.g. AssemblyAPI's // manualFetch) can produce consistently-shaped HttpFetchError instances // without duplicating the JSON/text body-parsing logic. export const buildHttpFetchError = async ( diff --git a/src/utils/copilotAPI.ts b/src/utils/assemblyAPI.ts similarity index 62% rename from src/utils/copilotAPI.ts rename to src/utils/assemblyAPI.ts index 5c065e03..80c2731c 100644 --- a/src/utils/copilotAPI.ts +++ b/src/utils/assemblyAPI.ts @@ -10,8 +10,6 @@ import { ClientResponse, ClientResponseSchema, ClientsResponseSchema, - ClientToken, - ClientTokenSchema, CompaniesResponse, CompaniesResponseSchema, CompanyCreateRequest, @@ -26,10 +24,6 @@ import { InternalUsersSchema, InvoiceResponse, InvoiceResponseSchema, - IUToken, - IUTokenSchema, - MeResponse, - MeResponseSchema, NotificationCreatedResponse, NotificationCreatedResponseSchema, NotificationRequestBody, @@ -37,20 +31,16 @@ import { PaymentsResponseSchema, PriceResponse, PriceResponseSchema, - PricesResponse, - PricesResponseSchema, ProductResponse, ProductResponseSchema, ProductsResponse, ProductsResponseSchema, - Token, - TokenSchema, WorkspaceResponse, WorkspaceResponseSchema, } from '@/type/common' import Bottleneck from 'bottleneck' -import type { CopilotAPI as SDK } from 'copilot-node-sdk' -import { copilotApi } from 'copilot-node-sdk' +import type { AssemblyAPI as SDK } from '@assembly-js/node-sdk' +import { assemblyApi } from '@assembly-js/node-sdk' import { z } from 'zod' import { API_DOMAIN } from '@/constant/domains' import httpStatus from 'http-status' @@ -59,11 +49,11 @@ import { MAX_INVOICE_LIST_LIMIT, } from '@/app/api/core/constants/limit' -export class CopilotAPI { - copilot: SDK +export class AssemblyAPI { + assembly: Promise - constructor(private token: string) { - this.copilot = copilotApi({ apiKey, token }) + constructor(private workspaceId: string) { + this.assembly = assemblyApi({ apiKey: `${this.workspaceId}/${apiKey}` }) } private async manualFetch( @@ -79,7 +69,7 @@ export class CopilotAPI { } console.info( - `CopilotAPI#manualFetch | url = ${url}, apiKey = ${apiKey}, workspaceId = ${workspaceId}`, + `AssemblyAPI#manualFetch | url = ${url}, apiKey = ${apiKey}, workspaceId = ${workspaceId}`, ) const resp = await fetch(url, { @@ -95,64 +85,27 @@ export class CopilotAPI { return await resp.json() } - // NOTE: Any method prefixed with _ is a API method that doesn't implement retry & delay - // NOTE: Any normal API method name implements `withRetry` with default config - - // Get Token Payload from copilot request token - async _getTokenPayload(): Promise { - const getTokenPayload = this.copilot.getTokenPayload - if (!getTokenPayload) { - console.error( - `CopilotAPI#getTokenPayload | Could not parse token payload for token ${this.token}`, - ) - return null - } - - return TokenSchema.parse(await getTokenPayload()) + async getSDK() { + return await this.assembly } - async _me(): Promise { - console.info('CopilotAPI#me | token =', this.token) - const tokenPayload = await this.getTokenPayload() - const id = tokenPayload?.internalUserId || tokenPayload?.clientId - if (!tokenPayload || !id) return null - - const retrieveCurrentUserInfo = tokenPayload.internalUserId - ? this.copilot.retrieveInternalUser - : this.copilot.retrieveClient - const currentUserInfo = await retrieveCurrentUserInfo({ id }) - - return MeResponseSchema.parse(currentUserInfo) - } + // NOTE: Any method prefixed with _ is a API method that doesn't implement retry & delay + // NOTE: Any normal API method name implements `withRetry` with default config async _getWorkspace(): Promise { - console.info('CopilotAPI#getWorkspace | token =', this.token) - return WorkspaceResponseSchema.parse(await this.copilot.retrieveWorkspace()) - } - - async _getClientTokenPayload(): Promise { - console.info('CopilotAPI#getClientTokenPayload | token =', this.token) - const tokenPayload = await this.getTokenPayload() - if (!tokenPayload) return null - - return ClientTokenSchema.parse(tokenPayload) - } - - async _getIUTokenPayload(): Promise { - console.info('CopilotAPI#getIUTokenPayload | token =', this.token) - const tokenPayload = await this.getTokenPayload() - if (!tokenPayload) return null - - return IUTokenSchema.parse(tokenPayload) + console.info('AssemblyAPI#getWorkspace | workspaceId =', this.workspaceId) + const sdk = await this.getSDK() + return WorkspaceResponseSchema.parse(await sdk.retrieveWorkspace()) } async _createClient( requestBody: ClientRequest, sendInvite: boolean = false, ): Promise { - console.info('CopilotAPI#createClient | token =', this.token) + console.info('AssemblyAPI#createClient | workspaceId =', this.workspaceId) + const sdk = await this.getSDK() return ClientResponseSchema.parse( - await this.copilot.createClient({ sendInvite, requestBody }), + await sdk.createClient({ sendInvite, requestBody }), ) } @@ -161,11 +114,10 @@ export class CopilotAPI { * Error handling: if copilot throws NOT FOUND error or BAD REQUEST error, return undefined. This is done as we don't want to terminate the process */ async _getClient(id: string): Promise { + const sdk = await this.getSDK() try { - console.info('CopilotAPI#getClient | token =', this.token) - return ClientResponseSchema.parse( - await this.copilot.retrieveClient({ id }), - ) + console.info('AssemblyAPI#getClient | workspaceId =', this.workspaceId) + return ClientResponseSchema.parse(await sdk.retrieveClient({ id })) } catch (error: unknown) { if ( typeof error === 'object' && @@ -179,7 +131,7 @@ export class CopilotAPI { error.status === httpStatus.NOT_FOUND ) { const errorBody = (error as { body: any }).body - console.info('CopilotAPI#getClient | message =', errorBody.message) + console.info('AssemblyAPI#getClient | message =', errorBody.message) return } } @@ -192,9 +144,10 @@ export class CopilotAPI { * Error handling: if copilot throws NOT FOUND error or BAD REQUEST error, return undefined. This is done as we don't want to terminate the process */ async _getClients(args: CopilotListArgs & { companyId?: string } = {}) { + const sdk = await this.getSDK() try { - console.info('CopilotAPI#getClients | token =', this.token) - return ClientsResponseSchema.parse(await this.copilot.listClients(args)) + console.info('AssemblyAPI#getClients | workspaceId =', this.workspaceId) + return ClientsResponseSchema.parse(await sdk.listClients(args)) } catch (error: unknown) { if ( typeof error === 'object' && @@ -208,7 +161,7 @@ export class CopilotAPI { error.status === httpStatus.NOT_FOUND ) { const errorBody = (error as { body: any }).body - console.info('CopilotAPI#getClients | message =', errorBody.message) + console.info('AssemblyAPI#getClients | message =', errorBody.message) return } } @@ -220,22 +173,23 @@ export class CopilotAPI { id: string, requestBody: ClientRequest, ): Promise { - console.info('CopilotAPI#updateClient | token =', this.token) + console.info('AssemblyAPI#updateClient | workspaceId =', this.workspaceId) + const sdk = await this.getSDK() return ClientResponseSchema.parse( - await this.copilot.updateClient({ id, requestBody }), + await sdk.updateClient({ id, requestBody }), ) } async _deleteClient(id: string) { - console.info('CopilotAPI#deleteClient | token =', this.token) - return await this.copilot.deleteClient({ id }) + console.info('AssemblyAPI#deleteClient | workspaceId =', this.workspaceId) + const sdk = await this.getSDK() + return await sdk.deleteClient({ id }) } async _createCompany(requestBody: CompanyCreateRequest) { - console.info('CopilotAPI#createCompany | token =', this.token) - return CompanyResponseSchema.parse( - await this.copilot.createCompany({ requestBody }), - ) + console.info('AssemblyAPI#createCompany | token =', this.workspaceId) + const sdk = await this.getSDK() + return CompanyResponseSchema.parse(await sdk.createCompany({ requestBody })) } /** @@ -244,10 +198,9 @@ export class CopilotAPI { */ async _getCompany(id: string): Promise { try { - console.info('CopilotAPI#getCompany | token =', this.token) - return CompanyResponseSchema.parse( - await this.copilot.retrieveCompany({ id }), - ) + console.info('AssemblyAPI#getCompany | token =', this.workspaceId) + const sdk = await this.getSDK() + return CompanyResponseSchema.parse(await sdk.retrieveCompany({ id })) } catch (error: unknown) { if ( typeof error === 'object' && @@ -261,7 +214,7 @@ export class CopilotAPI { error.status === httpStatus.NOT_FOUND ) { const errorBody = (error as { body: any }).body - console.info('CopilotAPI#getCompany | message =', errorBody.message) + console.info('AssemblyAPI#getCompany | message =', errorBody.message) return } } @@ -270,57 +223,63 @@ export class CopilotAPI { } async _getCompanies(args: CopilotListArgs = {}): Promise { - console.info('CopilotAPI#getCompanies | token =', this.token) - return CompaniesResponseSchema.parse(await this.copilot.listCompanies(args)) + console.info('AssemblyAPI#getCompanies | token =', this.workspaceId) + const sdk = await this.getSDK() + return CompaniesResponseSchema.parse(await sdk.listCompanies(args)) } async _getCompanyClients(companyId: string): Promise { - console.info('CopilotAPI#getCompanyClients | token =', this.token) + console.info('AssemblyAPI#getCompanyClients | token =', this.workspaceId) return (await this.getClients({ limit: 10000, companyId }))?.data || [] } async _getCustomFields(): Promise { - console.info('CopilotAPI#getCustomFields | token =', this.token) - return CustomFieldResponseSchema.parse( - await this.copilot.listCustomFields(), - ) + console.info('AssemblyAPI#getCustomFields | token =', this.workspaceId) + const sdk = await this.getSDK() + return CustomFieldResponseSchema.parse(await sdk.listCustomFields({})) } async _getInternalUsers( args: CopilotListArgs = {}, ): Promise { - console.info('CopilotAPI#getInternalUsers | token =', this.token) - return InternalUsersResponseSchema.parse( - await this.copilot.listInternalUsers(args), - ) + console.info('AssemblyAPI#getInternalUsers | token =', this.workspaceId) + const sdk = await this.getSDK() + return InternalUsersResponseSchema.parse(await sdk.listInternalUsers(args)) } async _getInternalUser(id: string): Promise { - console.info('CopilotAPI#getInternalUser | token =', this.token) - return InternalUsersSchema.parse( - await this.copilot.retrieveInternalUser({ id }), - ) + console.info('AssemblyAPI#getInternalUser | token =', this.workspaceId) + const sdk = await this.getSDK() + return InternalUsersSchema.parse(await sdk.retrieveInternalUser({ id })) } async _createNotification( requestBody: NotificationRequestBody, ): Promise { - console.info('CopilotAPI#createNotification | token =', this.token) - console.info('CopilotAPI#createNotification | requestBody =', requestBody) + console.info('AssemblyAPI#createNotification | token =', this.workspaceId) + console.info('AssemblyAPI#createNotification | requestBody =', requestBody) + const sdk = await this.getSDK() return NotificationCreatedResponseSchema.parse( - await this.copilot.createNotification({ + await sdk.createNotification({ requestBody, }), ) } async _markNotificationAsRead(id: string): Promise { - console.info('CopilotAPI#markNotificationAsRead | token =', this.token) - await this.copilot.markNotificationRead({ id }) + console.info( + 'AssemblyAPI#markNotificationAsRead | token =', + this.workspaceId, + ) + const sdk = await this.getSDK() + await sdk.markNotificationRead({ id }) } async _bulkMarkNotificationsAsRead(notificationIds: string[]): Promise { - console.info('CopilotAPI#markNotificationAsRead | token =', this.token) + console.info( + 'AssemblyAPI#markNotificationAsRead | token =', + this.workspaceId, + ) const markAsReadPromises = [] const bottleneck = new Bottleneck({ minTime: 250, maxConcurrent: 2 }) @@ -343,12 +302,13 @@ export class CopilotAPI { } async _deleteNotification(id: string): Promise { - console.info('CopilotAPI#deleteNotification | token =', this.token) - await this.copilot.deleteNotification({ id }) + console.info('AssemblyAPI#deleteNotification | token =', this.workspaceId) + const sdk = await this.getSDK() + await sdk.deleteNotification({ id }) } async _bulkDeleteNotifications(notificationIds: string[]): Promise { - console.info('CopilotAPI#deleteNotification | token =', this.token) + console.info('AssemblyAPI#deleteNotification | token =', this.workspaceId) const deletePromises = [] const bottleneck = new Bottleneck({ minTime: 250, maxConcurrent: 2 }) for (const notification of notificationIds) { @@ -391,10 +351,9 @@ export class CopilotAPI { } async _getProduct(id: string): Promise { - console.info('CopilotAPI#getProduct | token =', this.token) - return ProductResponseSchema.parse( - await this.copilot.retrieveProduct({ id }), - ) + console.info('AssemblyAPI#getProduct | token =', this.workspaceId) + const sdk = await this.getSDK() + return ProductResponseSchema.parse(await sdk.retrieveProduct({ id })) } async _getProducts({ @@ -406,39 +365,29 @@ export class CopilotAPI { nextToken?: string limit?: number }): Promise { - console.info('CopilotAPI#getProducts | token =', this.token) + console.info('AssemblyAPI#getProducts | token =', this.workspaceId) + const sdk = await this.getSDK() return ProductsResponseSchema.parse( - await this.copilot.listProducts({ name, nextToken, limit }), + await sdk.listProducts({ name, nextToken, limit }), ) } async _getPrice(id: string): Promise { - console.info('CopilotAPI#getPrice | token =', this.token) - return PriceResponseSchema.parse(await this.copilot.retrievePrice({ id })) - } - - async _getPrices( - productId?: string, - nextToken?: string, - limit?: string, - ): Promise { - console.info('CopilotAPI#getPrices | token =', this.token) - return PricesResponseSchema.parse( - await this.copilot.listPrices({ productId, nextToken, limit }), - ) + console.info('AssemblyAPI#getPrice | token =', this.workspaceId) + const sdk = await this.getSDK() + return PriceResponseSchema.parse(await sdk.retrievePrice({ id })) } async _getInvoice(id: string): Promise { - console.info('CopilotAPI#getInvoice | token =', this.token) - return InvoiceResponseSchema.parse( - await this.copilot.retrieveInvoice({ id }), - ) + console.info('AssemblyAPI#getInvoice | token =', this.workspaceId) + const sdk = await this.getSDK() + return InvoiceResponseSchema.parse(await sdk.retrieveInvoice({ id })) } async _getInvoices( workspaceId?: string, ): Promise { - console.info('CopilotAPI#getInvoices | token =', this.token) + console.info('AssemblyAPI#getInvoices | token =', this.workspaceId) const data = await this.manualFetch( 'invoices', { @@ -447,18 +396,19 @@ export class CopilotAPI { workspaceId, ) - console.info(`CopilotAPI#getInvoices | data length = ${data.data?.length}`) + console.info(`AssemblyAPI#getInvoices | data length = ${data.data?.length}`) return z.array(InvoiceResponseSchema).parse(data.data) } async _getPayments( invoiceId?: string, ): Promise { - console.info('CopilotAPI#getPayments | token =', this.token) + console.info('AssemblyAPI#getPayments | token =', this.workspaceId) + const sdk = await this.getSDK() return PaymentsResponseSchema.parse( - await this.copilot.listPayments({ + await sdk.listPayments({ invoiceId, - limit: MAX_ASSEMBLY_RESOURCE_LIST_LIMIT.toString(), + limit: MAX_ASSEMBLY_RESOURCE_LIST_LIMIT, }), ) } @@ -470,11 +420,7 @@ export class CopilotAPI { } // Methods wrapped with retry - getTokenPayload = this.wrapWithRetry(this._getTokenPayload) - me = this.wrapWithRetry(this._me) getWorkspace = this.wrapWithRetry(this._getWorkspace) - getClientTokenPayload = this.wrapWithRetry(this._getClientTokenPayload) - getIUTokenPayload = this.wrapWithRetry(this._getIUTokenPayload) createClient = this.wrapWithRetry(this._createClient) getClient = this.wrapWithRetry(this._getClient) getClients = this.wrapWithRetry(this._getClients) @@ -497,7 +443,6 @@ export class CopilotAPI { getProduct = this.wrapWithRetry(this._getProduct) getProducts = this.wrapWithRetry(this._getProducts) getPrice = this.wrapWithRetry(this._getPrice) - getPrices = this.wrapWithRetry(this._getPrices) getInvoice = this.wrapWithRetry(this._getInvoice) getInvoices = this.wrapWithRetry(this._getInvoices) getPayments = this.wrapWithRetry(this._getPayments) From bbd3f1d56005a5e31ea76622e26a479d33b402d8 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 18 Aug 2026 21:38:06 +0545 Subject: [PATCH 03/11] refactor(OUT-4067): extract token decode into AssemblyTokenPayload Co-Authored-By: Claude Opus 4.8 --- src/action/copilot.action.ts | 6 +- src/app/api/core/utils/authenticate.ts | 8 +- src/app/api/quickbooks/cron/cron.service.ts | 7 +- src/cmd/backfillProductInfo/index.ts | 5 +- src/cmd/syncMissedInvoices/index.ts | 5 +- src/cmd/syncMissedProducts/index.ts | 5 +- src/utils/assemblyTokenPayload.ts | 21 ++++++ test/unit/utils/assemblyTokenPayload.test.ts | 79 ++++++++++++++++++++ 8 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 src/utils/assemblyTokenPayload.ts create mode 100644 test/unit/utils/assemblyTokenPayload.test.ts diff --git a/src/action/copilot.action.ts b/src/action/copilot.action.ts index d63efcf9..b805ee67 100644 --- a/src/action/copilot.action.ts +++ b/src/action/copilot.action.ts @@ -1,8 +1,6 @@ import { Token } from '@/type/common' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' export async function getTokenPayload(token: string): Promise { - const copilotClient = new CopilotAPI(token) - const payload = await copilotClient.getTokenPayload() - return payload as Token + return await new AssemblyTokenPayload().getTokenPayload(token) } diff --git a/src/app/api/core/utils/authenticate.ts b/src/app/api/core/utils/authenticate.ts index f08630ef..22542b45 100644 --- a/src/app/api/core/utils/authenticate.ts +++ b/src/app/api/core/utils/authenticate.ts @@ -1,4 +1,4 @@ -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { NextRequest } from 'next/server' import User from '@/app/api/core/models/User.model' import { z } from 'zod' @@ -8,8 +8,8 @@ import httpStatus from 'http-status' import { withRetry } from '@/app/api/core/utils/withRetry' export const _authenticateWithToken = async (token: string): Promise => { - const copilotClient = new CopilotAPI(token) - const payload = TokenSchema.safeParse(await copilotClient.getTokenPayload()) + const tokenPayload = await new AssemblyTokenPayload().getTokenPayload(token) + const payload = TokenSchema.safeParse(tokenPayload) if (!payload.success) { throw new APIError(httpStatus.UNAUTHORIZED, 'Failed to authenticate token') @@ -35,7 +35,7 @@ export const authenticateWithToken = (...args: unknown[]) => * Token parser and authentication util * * `authenticate` takes in the current request, parses the "token" searchParam from it, - * uses `CopilotAPI` to check if the user token is valid + * uses `AssemblyTokenPayload` to check if the user token is valid * and finally returns an instance of `User` that is associated with this request */ const authenticate = async (req: NextRequest) => { diff --git a/src/app/api/quickbooks/cron/cron.service.ts b/src/app/api/quickbooks/cron/cron.service.ts index 3c159537..81192e45 100644 --- a/src/app/api/quickbooks/cron/cron.service.ts +++ b/src/app/api/quickbooks/cron/cron.service.ts @@ -7,7 +7,7 @@ import { copilotAPIKey } from '@/config' import { db } from '@/db' import { QBSyncLog } from '@/db/schema/qbSyncLogs' import { getAllActivePortalConnections } from '@/db/service/token.service' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { encodePayload } from '@/utils/crypto' import CustomLogger from '@/utils/logger' import * as Sentry from '@sentry/nextjs' @@ -29,8 +29,9 @@ export default class CronService { const token = encodePayload(copilotAPIKey, payload) // check if token is valid or not - const copilot = new CopilotAPI(token) - const tokenPayload = await copilot.getTokenPayload() + const tokenPayload = await new AssemblyTokenPayload().getTokenPayload( + token, + ) CustomLogger.info({ obj: { copilotApiCronToken: token, tokenPayload }, message: diff --git a/src/cmd/backfillProductInfo/index.ts b/src/cmd/backfillProductInfo/index.ts index ae8e759a..0f0d1ff2 100644 --- a/src/cmd/backfillProductInfo/index.ts +++ b/src/cmd/backfillProductInfo/index.ts @@ -4,7 +4,7 @@ import { BackfillProductInfoService } from '@/cmd/backfillProductInfo/backfillPr import { copilotAPIKey } from '@/config' import { PortalConnectionWithSettingType } from '@/db/schema/qbPortalConnections' import { getAllActivePortalConnections } from '@/db/service/token.service' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { encodePayload } from '@/utils/crypto' import CustomLogger from '@/utils/logger' @@ -54,8 +54,7 @@ async function initiateProcess(connection: PortalConnectionWithSettingType) { } const token = encodePayload(copilotAPIKey, payload) - const copilot = new CopilotAPI(token) - const tokenPayload = await copilot.getTokenPayload() + const tokenPayload = await new AssemblyTokenPayload().getTokenPayload(token) CustomLogger.info({ obj: { copilotApiCronToken: token, tokenPayload }, message: diff --git a/src/cmd/syncMissedInvoices/index.ts b/src/cmd/syncMissedInvoices/index.ts index 2eb1c6e2..f4cd3457 100644 --- a/src/cmd/syncMissedInvoices/index.ts +++ b/src/cmd/syncMissedInvoices/index.ts @@ -4,7 +4,7 @@ import { SyncMissedInvoicesService } from '@/cmd/syncMissedInvoices/syncMissedIn import { copilotAPIKey } from '@/config' import { PortalConnectionWithSettingType } from '@/db/schema/qbPortalConnections' import { getAllActivePortalConnections } from '@/db/service/token.service' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { encodePayload } from '@/utils/crypto' import CustomLogger from '@/utils/logger' @@ -53,8 +53,7 @@ async function initiateProcess(connection: PortalConnectionWithSettingType) { } const token = encodePayload(copilotAPIKey, payload) - const copilot = new CopilotAPI(token) - const tokenPayload = await copilot.getTokenPayload() + const tokenPayload = await new AssemblyTokenPayload().getTokenPayload(token) CustomLogger.info({ obj: { copilotApiCronToken: token, tokenPayload }, message: diff --git a/src/cmd/syncMissedProducts/index.ts b/src/cmd/syncMissedProducts/index.ts index 610903ad..5a6cd63d 100644 --- a/src/cmd/syncMissedProducts/index.ts +++ b/src/cmd/syncMissedProducts/index.ts @@ -4,7 +4,7 @@ import { SyncMissedProductsService } from '@/cmd/syncMissedProducts/syncMissedPr import { copilotAPIKey } from '@/config' import { PortalConnectionWithSettingType } from '@/db/schema/qbPortalConnections' import { getAllActivePortalConnections } from '@/db/service/token.service' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { encodePayload } from '@/utils/crypto' import CustomLogger from '@/utils/logger' @@ -61,8 +61,7 @@ async function initiateProcess(connection: PortalConnectionWithSettingType) { } const token = encodePayload(copilotAPIKey, payload) - const copilot = new CopilotAPI(token) - const tokenPayload = await copilot.getTokenPayload() + const tokenPayload = await new AssemblyTokenPayload().getTokenPayload(token) CustomLogger.info({ obj: { copilotApiCronToken: token, tokenPayload }, message: diff --git a/src/utils/assemblyTokenPayload.ts b/src/utils/assemblyTokenPayload.ts new file mode 100644 index 00000000..8321aa57 --- /dev/null +++ b/src/utils/assemblyTokenPayload.ts @@ -0,0 +1,21 @@ +import { copilotAPIKey } from '@/config' +import { TokenSchema } from '@/type/common' +import { assemblyApi } from '@assembly-js/node-sdk' + +// Decodes a request token into its payload. Kept separate from AssemblyAPI +// (which is workspace-scoped) so the token-scoped SDK stays out of the +// wholesale-mocked client — the auth boundary is the only caller. +export class AssemblyTokenPayload { + async getTokenPayload(token: string) { + const sdk = await assemblyApi({ apiKey: copilotAPIKey, token }) + const getTokenPayload = sdk.getTokenPayload + if (!getTokenPayload) { + console.error( + `AssemblyTokenPayload#getTokenPayload | Could not parse token payload for token ${token}`, + ) + return null + } + + return TokenSchema.parse(await getTokenPayload()) + } +} diff --git a/test/unit/utils/assemblyTokenPayload.test.ts b/test/unit/utils/assemblyTokenPayload.test.ts new file mode 100644 index 00000000..3842295a --- /dev/null +++ b/test/unit/utils/assemblyTokenPayload.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Simulate the real @assembly-js/node-sdk v4 contract: assemblyApi() returns a +// fresh, per-call client whose getTokenPayload() resolves the payload derived +// from THIS call's token (captured in a closure), never shared global state. +// A staggered delay inside getTokenPayload forces concurrent calls to resolve +// out of order — so any cross-call state leak in AssemblyTokenPayload would +// surface as a mismatched workspaceId. +vi.mock('@assembly-js/node-sdk', () => ({ + assemblyApi: vi.fn(async ({ token }: { token: string }) => { + const payload = JSON.parse(token) as { + workspaceId: string + delayMs: number + } + return { + getTokenPayload: async () => { + await new Promise((resolve) => setTimeout(resolve, payload.delayMs)) + return { workspaceId: payload.workspaceId } + }, + } + }), +})) + +import { assemblyApi } from '@assembly-js/node-sdk' +import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' + +// In this test a "token" is just a JSON string carrying the workspace it belongs +// to and how long its decode should take. +const makeToken = (workspaceId: string, delayMs: number) => + JSON.stringify({ workspaceId, delayMs }) + +describe('AssemblyTokenPayload.getTokenPayload — concurrency isolation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the matching workspaceId for a single call', async () => { + const payload = await new AssemblyTokenPayload().getTokenPayload( + makeToken('ws-solo', 0), + ) + expect(payload?.workspaceId).toBe('ws-solo') + }) + + it('keeps each workspace isolated across interleaved concurrent calls', async () => { + // 20 different workspaces, fired together. Earlier calls are given LONGER + // delays so completion order is the reverse of call order — maximising + // interleaving at both await points inside getTokenPayload. + const count = 20 + const workspaceIds = Array.from({ length: count }, (_, i) => `ws-${i}`) + + const results = await Promise.all( + workspaceIds.map((workspaceId, i) => + new AssemblyTokenPayload().getTokenPayload( + makeToken(workspaceId, (count - i) * 2), + ), + ), + ) + + // Every call must return its OWN workspace — no bleed between requests. + results.forEach((payload, i) => { + expect(payload?.workspaceId).toBe(workspaceIds[i]) + }) + }) + + it('builds one independent SDK per call (no shared client)', async () => { + await Promise.all([ + new AssemblyTokenPayload().getTokenPayload(makeToken('ws-a', 6)), + new AssemblyTokenPayload().getTokenPayload(makeToken('ws-b', 3)), + new AssemblyTokenPayload().getTokenPayload(makeToken('ws-c', 0)), + ]) + + // assemblyApi is invoked once per call, each with that call's own token. + expect(assemblyApi).toHaveBeenCalledTimes(3) + const tokensSeen = ( + assemblyApi as unknown as ReturnType + ).mock.calls.map(([arg]) => JSON.parse(arg.token).workspaceId) + expect(tokensSeen.sort()).toEqual(['ws-a', 'ws-b', 'ws-c']) + }) +}) From be03c7d2ea42eb512f070721e806ed0ebd5fed40 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 18 Aug 2026 21:38:08 +0545 Subject: [PATCH 04/11] refactor(OUT-4067): require deliveryTargets fields in NotificationRequestBodySchema Co-Authored-By: Claude Opus 4.8 --- src/type/common.ts | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/type/common.ts b/src/type/common.ts index 165f3cba..7c15b4fa 100644 --- a/src/type/common.ts +++ b/src/type/common.ts @@ -154,7 +154,7 @@ export const ClientRequestSchema = z.object({ familyName: z.string(), email: z.string().email(), companyId: z.string().uuid().optional(), - // NOTE: customFields can also be passed as a JSON object, but CopilotAPI has its type defined to stringified JSON + // NOTE: customFields can also be passed as a JSON object, but AssemblyAPI has its type defined to stringified JSON customFields: z.string().optional(), }) export type ClientRequest = z.infer @@ -183,24 +183,22 @@ export type InternalUsersResponse = z.infer export const NotificationRequestBodySchema = z.object({ senderId: z.string(), recipientId: z.string(), - deliveryTargets: z - .object({ - inProduct: z - .object({ - title: z.string(), - body: z.string().optional(), - }) - .optional(), - email: z - .object({ - subject: z.string().optional(), - header: z.string().optional(), - title: z.string().optional(), - body: z.string().optional(), - }) - .optional(), - }) - .optional(), + deliveryTargets: z.object({ + inProduct: z + .object({ + title: z.string(), + body: z.string().optional(), + }) + .optional(), + email: z + .object({ + subject: z.string(), + header: z.string(), + title: z.string(), + body: z.string().optional(), + }) + .optional(), + }), }) export type NotificationRequestBody = z.infer< From 0e16c0983f54e3405ceedc73acba7a00dd74440d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 18 Aug 2026 21:38:25 +0545 Subject: [PATCH 05/11] test(OUT-4067): update mocks and fixtures for the assembly SDK migration Co-Authored-By: Claude Opus 4.8 --- test/diagrams/test-flow.md | 6 +-- test/helpers/invoiceCreatedTestSetup.ts | 4 +- test/helpers/invoiceDeletedTestSetup.ts | 4 +- test/helpers/invoicePaidTestSetup.ts | 4 +- test/helpers/invoiceVoidedTestSetup.ts | 4 +- test/helpers/mocks.ts | 38 ++++++++----------- test/helpers/paymentSucceededTestSetup.ts | 4 +- test/helpers/productCreatedTestSetup.ts | 4 +- .../accounts/updateAccountRefs.test.ts | 2 +- .../invoiceCreated/useCompanyNameFlag.test.ts | 4 +- .../copilotInvoiceNotFound.test.ts | 4 +- test/integration/setup.ts | 27 ++++++++++--- .../token/updateAccountRefs.test.ts | 6 +-- .../webhook/handleInvoiceCreated.test.ts | 8 ++-- .../invoice/invoice.service.docNumber.test.ts | 4 +- .../unit/quickbooks/syncErrorNotifier.test.ts | 3 +- 16 files changed, 68 insertions(+), 58 deletions(-) diff --git a/test/diagrams/test-flow.md b/test/diagrams/test-flow.md index 37257acc..372758a7 100644 --- a/test/diagrams/test-flow.md +++ b/test/diagrams/test-flow.md @@ -26,7 +26,7 @@ flowchart TD C --> D["Per-file vi.mock(...) at top of file
(every external boundary stubbed)"] D --> D1["vi.mock @/db
(no real Postgres at all)"] - D --> D2["vi.mock @/utils/copilotAPI"] + D --> D2["vi.mock @/utils/assemblyAPI"] D --> D3["vi.mock @/utils/intuitAPI"] D --> D4["vi.mock @sentry/nextjs"] D --> D5["vi.mock @/utils/logger / sleep / auth"] @@ -81,7 +81,7 @@ flowchart LR C4["dotenv loads .env.test
(override: true)"] D["Single forked worker boots
(inherits env vars)"] E["setupFiles → test/integration/setup.ts"] - E1["vi.mock @/utils/copilotAPI"] + E1["vi.mock @/utils/assemblyAPI"] E2["vi.mock @/utils/intuitAPI"] E3["vi.mock @/utils/intuit
(pinned on globalThis)"] E4["vi.mock @sentry/nextjs"] @@ -111,7 +111,7 @@ flowchart LR J["Next.js route
src/app/api/quickbooks/webhook/route.ts"] K["Controller → WebhookService
→ Invoice/Product/Payment services"] L1[("Postgres in Testcontainer
via @/db Drizzle singleton")] - L2["MOCKED CopilotAPI"] + L2["MOCKED AssemblyAPI"] L3["MOCKED IntuitAPI"] M["Response"] N["expect(status / DB rows / mock.calls)"] diff --git a/test/helpers/invoiceCreatedTestSetup.ts b/test/helpers/invoiceCreatedTestSetup.ts index fdcbdac7..88ea8ef4 100644 --- a/test/helpers/invoiceCreatedTestSetup.ts +++ b/test/helpers/invoiceCreatedTestSetup.ts @@ -2,14 +2,14 @@ import { beforeEach, afterEach, vi } from 'vitest' import { truncateAllTestTables } from '@test/helpers/testDb' import { installMockApis, - type MockCopilotAPI, + type MockAssemblyAPI, type MockIntuitAPI, } from '@test/helpers/mocks' type InstallOpts = Parameters[0] export interface InvoiceCreatedTestHandle { - copilot: MockCopilotAPI + copilot: MockAssemblyAPI intuit: MockIntuitAPI } diff --git a/test/helpers/invoiceDeletedTestSetup.ts b/test/helpers/invoiceDeletedTestSetup.ts index 105d0b20..abf70507 100644 --- a/test/helpers/invoiceDeletedTestSetup.ts +++ b/test/helpers/invoiceDeletedTestSetup.ts @@ -2,14 +2,14 @@ import { beforeEach, afterEach, vi } from 'vitest' import { truncateAllTestTables } from '@test/helpers/testDb' import { installMockApis, - type MockCopilotAPI, + type MockAssemblyAPI, type MockIntuitAPI, } from '@test/helpers/mocks' type InstallOpts = Parameters[0] export interface InvoiceDeletedTestHandle { - copilot: MockCopilotAPI + copilot: MockAssemblyAPI intuit: MockIntuitAPI } diff --git a/test/helpers/invoicePaidTestSetup.ts b/test/helpers/invoicePaidTestSetup.ts index 0b2608e5..e76e21d6 100644 --- a/test/helpers/invoicePaidTestSetup.ts +++ b/test/helpers/invoicePaidTestSetup.ts @@ -2,14 +2,14 @@ import { beforeEach, afterEach, vi } from 'vitest' import { truncateAllTestTables } from '@test/helpers/testDb' import { installMockApis, - type MockCopilotAPI, + type MockAssemblyAPI, type MockIntuitAPI, } from '@test/helpers/mocks' type InstallOpts = Parameters[0] export interface InvoicePaidTestHandle { - copilot: MockCopilotAPI + copilot: MockAssemblyAPI intuit: MockIntuitAPI } diff --git a/test/helpers/invoiceVoidedTestSetup.ts b/test/helpers/invoiceVoidedTestSetup.ts index dd3d7553..65cef3f3 100644 --- a/test/helpers/invoiceVoidedTestSetup.ts +++ b/test/helpers/invoiceVoidedTestSetup.ts @@ -2,14 +2,14 @@ import { beforeEach, afterEach, vi } from 'vitest' import { truncateAllTestTables } from '@test/helpers/testDb' import { installMockApis, - type MockCopilotAPI, + type MockAssemblyAPI, type MockIntuitAPI, } from '@test/helpers/mocks' type InstallOpts = Parameters[0] export interface InvoiceVoidedTestHandle { - copilot: MockCopilotAPI + copilot: MockAssemblyAPI intuit: MockIntuitAPI } diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index b11d0d79..a93798a7 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -1,10 +1,8 @@ import { vi, type Mock } from 'vitest' -import { CopilotAPI } from '@/utils/copilotAPI' +import { AssemblyAPI } from '@/utils/assemblyAPI' import IntuitAPI from '@/utils/intuitAPI' import { TEST_INCOME_ACCOUNT_REF, - TEST_INTERNAL_USER_ID, - TEST_PORTAL_ID, TEST_COPILOT_INVOICE_ID, TEST_INVOICE_NUMBER, TEST_QB_PURCHASE_ID, @@ -22,22 +20,18 @@ type MockMethodOverrides = { : never]?: Mock } -type CopilotAPIOverrides = MockMethodOverrides +type AssemblyAPIOverrides = MockMethodOverrides type IntuitAPIOverrides = MockMethodOverrides /** - * Factory for a mocked CopilotAPI instance. + * Factory for a mocked AssemblyAPI instance. * - * Tests mock the CopilotAPI module with `vi.mock('@/utils/copilotAPI')`, then - * wire each `new CopilotAPI(token)` call to an object produced by this factory. + * Tests mock the AssemblyAPI module with `vi.mock('@/utils/assemblyAPI')`, then + * wire each `new AssemblyAPI(token)` call to an object produced by this factory. * Override any method via the `overrides` arg to tailor behavior per test. */ -export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) { +export function createMockAssemblyAPI(overrides: AssemblyAPIOverrides = {}) { return { - getTokenPayload: vi.fn().mockResolvedValue({ - workspaceId: TEST_PORTAL_ID, - internalUserId: TEST_INTERNAL_USER_ID, - }), getProduct: vi.fn().mockResolvedValue({ id: '2cf93cf0-45fa-485f-b584-03c2c38a3999', name: 'Test Product', @@ -162,31 +156,31 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) { } } -export type MockCopilotAPI = ReturnType +export type MockAssemblyAPI = ReturnType export type MockIntuitAPI = ReturnType /** - * Wires the module-mocked CopilotAPI + IntuitAPI to shared instances and + * Wires the module-mocked AssemblyAPI + IntuitAPI to shared instances and * returns them so tests can assert on calls. Uses `function` (not arrow) so * the mock is callable with `new`. * - * Caveat: one request may `new CopilotAPI(...)` several times (auth + + * Caveat: one request may `new AssemblyAPI(...)` several times (auth + * invoice flow) — all share this instance, so call counts sum across sites. */ export function installMockApis( opts: { - copilot?: MockCopilotAPI + copilot?: MockAssemblyAPI intuit?: MockIntuitAPI } = {}, -): { copilot: MockCopilotAPI; intuit: MockIntuitAPI } { - const copilot = opts.copilot ?? createMockCopilotAPI() +): { copilot: MockAssemblyAPI; intuit: MockIntuitAPI } { + const copilot = opts.copilot ?? createMockAssemblyAPI() const intuit = opts.intuit ?? createMockIntuitAPI() - vi.mocked(CopilotAPI).mockImplementation(function ( + vi.mocked(AssemblyAPI).mockImplementation(function ( this: unknown, - ): CopilotAPI { - return copilot as unknown as CopilotAPI - } as unknown as typeof CopilotAPI) + ): AssemblyAPI { + return copilot as unknown as AssemblyAPI + } as unknown as typeof AssemblyAPI) vi.mocked(IntuitAPI).mockImplementation(function (this: unknown): IntuitAPI { return intuit as unknown as IntuitAPI diff --git a/test/helpers/paymentSucceededTestSetup.ts b/test/helpers/paymentSucceededTestSetup.ts index 2a7b5c0a..7653544a 100644 --- a/test/helpers/paymentSucceededTestSetup.ts +++ b/test/helpers/paymentSucceededTestSetup.ts @@ -2,14 +2,14 @@ import { beforeEach, afterEach, vi } from 'vitest' import { truncateAllTestTables } from '@test/helpers/testDb' import { installMockApis, - type MockCopilotAPI, + type MockAssemblyAPI, type MockIntuitAPI, } from '@test/helpers/mocks' type InstallOpts = Parameters[0] export interface PaymentSucceededTestHandle { - copilot: MockCopilotAPI + copilot: MockAssemblyAPI intuit: MockIntuitAPI } diff --git a/test/helpers/productCreatedTestSetup.ts b/test/helpers/productCreatedTestSetup.ts index 81a3432c..f4e3bc2c 100644 --- a/test/helpers/productCreatedTestSetup.ts +++ b/test/helpers/productCreatedTestSetup.ts @@ -2,14 +2,14 @@ import { beforeEach, afterEach, vi } from 'vitest' import { truncateAllTestTables } from '@test/helpers/testDb' import { installMockApis, - type MockCopilotAPI, + type MockAssemblyAPI, type MockIntuitAPI, } from '@test/helpers/mocks' type InstallOpts = Parameters[0] export interface ProductCreatedTestHandle { - copilot: MockCopilotAPI + copilot: MockAssemblyAPI intuit: MockIntuitAPI } diff --git a/test/integration/quickbooks/accounts/updateAccountRefs.test.ts b/test/integration/quickbooks/accounts/updateAccountRefs.test.ts index 3d571cc1..8ef956c0 100644 --- a/test/integration/quickbooks/accounts/updateAccountRefs.test.ts +++ b/test/integration/quickbooks/accounts/updateAccountRefs.test.ts @@ -105,7 +105,7 @@ describe('PATCH /api/quickbooks/accounts', () => { }) it("cannot modify another portal's row (tenant isolation)", async () => { - // CopilotAPI mock always decrypts to TEST_PORTAL_ID, so this verifies the + // AssemblyAPI mock always decrypts to TEST_PORTAL_ID, so this verifies the // WHERE-clause scope works — not that a foreign token would be rejected. await seedHealthyPortal() const OTHER = 'other-portal-99999999' diff --git a/test/integration/quickbooks/invoiceCreated/useCompanyNameFlag.test.ts b/test/integration/quickbooks/invoiceCreated/useCompanyNameFlag.test.ts index 2b515dec..ef63b186 100644 --- a/test/integration/quickbooks/invoiceCreated/useCompanyNameFlag.test.ts +++ b/test/integration/quickbooks/invoiceCreated/useCompanyNameFlag.test.ts @@ -5,13 +5,13 @@ import { QBCustomers } from '@/db/schema/qbCustomers' import invoiceCreatedPayload from '@test/fixtures/invoiceCreated.webhook' import { seedHealthyPortal, seedProductSync } from '@test/helpers/seed' -import { createMockCopilotAPI } from '@test/helpers/mocks' +import { createMockAssemblyAPI } from '@test/helpers/mocks' import { setupInvoiceCreatedTest } from '@test/helpers/invoiceCreatedTestSetup' import { postWebhook } from '@test/helpers/webhook' describe('POST /api/quickbooks/webhook — invoice.created (invoice belongs to a company and the "use company name" setting is on)', () => { const apis = setupInvoiceCreatedTest(() => ({ - copilot: createMockCopilotAPI({ + copilot: createMockAssemblyAPI({ // Payload has companyId but no clientId, so any client lookup is wrong. getClient: vi.fn().mockResolvedValue(undefined), getCompany: vi.fn().mockResolvedValue({ diff --git a/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts b/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts index 37bfd7f5..1ffe8bc6 100644 --- a/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts +++ b/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts @@ -11,13 +11,13 @@ import { seedQBInvoiceSync, TEST_COPILOT_PAYMENT_ID, } from '@test/helpers/seed' -import { createMockCopilotAPI } from '@test/helpers/mocks' +import { createMockAssemblyAPI } from '@test/helpers/mocks' import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' import { postWebhook } from '@test/helpers/webhook' describe('POST /api/quickbooks/webhook — payment.succeeded (Copilot returns no invoice)', () => { const apis = setupPaymentSucceededTest(() => ({ - copilot: createMockCopilotAPI({ + copilot: createMockAssemblyAPI({ getInvoice: vi.fn().mockResolvedValue(undefined), }), })) diff --git a/test/integration/setup.ts b/test/integration/setup.ts index 2e210acd..2617a48c 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -1,14 +1,15 @@ import { vi } from 'vitest' +import { TEST_INTERNAL_USER_ID, TEST_PORTAL_ID } from '@test/helpers/seed' /** * Shared module mocks for all integration tests. * * Loaded via `setupFiles` in vitest.config.ts (integration project). Each * test file still configures per-test behavior in beforeEach via - * `vi.mocked(CopilotAPI).mockImplementation(...)`. + * `vi.mocked(AssemblyAPI).mockImplementation(...)`. * * Why here instead of per-file: - * - Explicit factory for CopilotAPI/IntuitAPI avoids evaluating the real + * - Explicit factory for AssemblyAPI/IntuitAPI avoids evaluating the real * modules (copilot-node-sdk has an ESM directory-import that breaks). * - Sentry has to be stubbed because withRetry.ts calls * `scope.addEventProcessor(...)` inside Sentry.withScope. @@ -18,18 +19,32 @@ import { vi } from 'vitest' */ type MockSingletons = { - CopilotAPI?: ReturnType + AssemblyAPI?: ReturnType IntuitAPI?: ReturnType } const g = globalThis as typeof globalThis & { __qbsync_test_mocks?: MockSingletons } g.__qbsync_test_mocks ??= {} -g.__qbsync_test_mocks.CopilotAPI ??= vi.fn() +g.__qbsync_test_mocks.AssemblyAPI ??= vi.fn() g.__qbsync_test_mocks.IntuitAPI ??= vi.fn() -vi.mock('@/utils/copilotAPI', () => ({ - CopilotAPI: g.__qbsync_test_mocks!.CopilotAPI!, +vi.mock('@/utils/assemblyAPI', () => ({ + AssemblyAPI: g.__qbsync_test_mocks!.AssemblyAPI!, +})) + +// Token decode lives in a separate module (AssemblyTokenPayload) so it stays +// out of the wholesale-mocked AssemblyAPI. Plain class — not vi.fn — so it +// survives the clearAllMocks/restoreAllMocks that per-test setup helpers call. +vi.mock('@/utils/assemblyTokenPayload', () => ({ + AssemblyTokenPayload: class { + async getTokenPayload() { + return { + workspaceId: TEST_PORTAL_ID, + internalUserId: TEST_INTERNAL_USER_ID, + } + } + }, })) vi.mock('@/utils/intuitAPI', () => ({ diff --git a/test/unit/api/quickbooks/token/updateAccountRefs.test.ts b/test/unit/api/quickbooks/token/updateAccountRefs.test.ts index 3a61e94e..640001f4 100644 --- a/test/unit/api/quickbooks/token/updateAccountRefs.test.ts +++ b/test/unit/api/quickbooks/token/updateAccountRefs.test.ts @@ -14,15 +14,15 @@ vi.mock('@/utils/logger', () => ({ default: { info: vi.fn(), error: vi.fn() }, })) -// Stub IntuitAPI + CopilotAPI so importing TokenService doesn't transitively +// Stub IntuitAPI + AssemblyAPI so importing TokenService doesn't transitively // load copilot-node-sdk (which has an ESM directory-import that breaks under // vitest). See docs/vitest-gotchas.md. vi.mock('@/utils/intuitAPI', () => ({ default: vi.fn(), IntuitAPIErrorMessage: '#IntuitAPIErrorMessage#', })) -vi.mock('@/utils/copilotAPI', () => ({ - CopilotAPI: vi.fn(), +vi.mock('@/utils/assemblyAPI', () => ({ + AssemblyAPI: vi.fn(), })) import { TokenService } from '@/app/api/quickbooks/token/token.service' diff --git a/test/unit/api/quickbooks/webhook/handleInvoiceCreated.test.ts b/test/unit/api/quickbooks/webhook/handleInvoiceCreated.test.ts index 7e0a9803..9f45954c 100644 --- a/test/unit/api/quickbooks/webhook/handleInvoiceCreated.test.ts +++ b/test/unit/api/quickbooks/webhook/handleInvoiceCreated.test.ts @@ -35,11 +35,11 @@ vi.mock('@/utils/auth', () => ({ refreshTokenExpireMessage: 'Refresh token is expired', })) -// `@/utils/copilotAPI` pulls in `copilot-node-sdk`, which has an ESM directory +// `@/utils/assemblyAPI` pulls in `copilot-node-sdk`, which has an ESM directory // import that breaks under Vitest's resolver. We don't exercise it here, so // stub it out completely. -vi.mock('@/utils/copilotAPI', () => ({ - CopilotAPI: vi.fn(), +vi.mock('@/utils/assemblyAPI', () => ({ + AssemblyAPI: vi.fn(), })) // `@/utils/intuitAPI` is imported transitively by `@/utils/error` @@ -60,7 +60,7 @@ vi.mock('@/db', () => ({ // `new SyncLogService(...)` requires a constructable mock — `vi.fn()` arrow // implementations aren't constructors, so we use a plain `function` factory. // See `docs/vitest-gotchas.md` and the same pattern in -// `test/integration/setup.ts` for IntuitAPI/CopilotAPI. +// `test/integration/setup.ts` for IntuitAPI/AssemblyAPI. const claimWebhookEvent = vi.fn() const updateOrCreateQBSyncLog = vi.fn() vi.mock('@/app/api/quickbooks/syncLog/syncLog.service', () => ({ diff --git a/test/unit/app/api/quickbooks/invoice/invoice.service.docNumber.test.ts b/test/unit/app/api/quickbooks/invoice/invoice.service.docNumber.test.ts index a9b20aef..0611e939 100644 --- a/test/unit/app/api/quickbooks/invoice/invoice.service.docNumber.test.ts +++ b/test/unit/app/api/quickbooks/invoice/invoice.service.docNumber.test.ts @@ -32,8 +32,8 @@ vi.mock('@/utils/logger', () => ({ default: { info: vi.fn(), error: vi.fn() }, })) -vi.mock('@/utils/copilotAPI', () => ({ - CopilotAPI: vi.fn(), +vi.mock('@/utils/assemblyAPI', () => ({ + AssemblyAPI: vi.fn(), })) vi.mock('@/utils/intuitAPI', () => ({ diff --git a/test/unit/quickbooks/syncErrorNotifier.test.ts b/test/unit/quickbooks/syncErrorNotifier.test.ts index deb6c752..fd248ca9 100644 --- a/test/unit/quickbooks/syncErrorNotifier.test.ts +++ b/test/unit/quickbooks/syncErrorNotifier.test.ts @@ -4,7 +4,7 @@ import { NotificationActions } from '@/app/api/core/types/notification' import { QBSyncLogSelectSchemaType } from '@/db/schema/qbSyncLogs' // Stub Sentry + logger before importing the SUT — its transitive imports pull -// in CopilotAPI/IntuitAPI which try to construct real SDK clients at import time. +// in AssemblyAPI/IntuitAPI which try to construct real SDK clients at import time. vi.mock('@sentry/nextjs', () => ({ withScope: vi.fn(), captureMessage: vi.fn(), @@ -69,6 +69,7 @@ const baseLog: QBSyncLogSelectSchemaType = { errorMessage: 'Closed accounting period', errorCode: String(QBOErrorCodes.CLOSED_PERIOD), category: 'qb_api_error' as never, + shouldRetry: false, attempt: 0, createdAt: new Date(), updatedAt: new Date(), From 37263c7de275fae4568f534d2c8bd02ecfe234ca Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 18 Aug 2026 21:38:26 +0545 Subject: [PATCH 06/11] docs: add CLAUDE.md project guide Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c7dee4e4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,160 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this app is + +A multi-tenant Next.js (App Router) service that synchronizes Copilot / Assembly workspaces with QuickBooks Online (QBO). It runs on Vercel, persists state in Postgres (Supabase in prod, Drizzle ORM throughout), and reacts to Copilot webhooks (`invoice.created/updated/paid/voided/deleted`, `product.updated`, `price.created`, `payment.succeeded`) by mirroring those entities into the corresponding QBO realm. + +A "portal" is one Copilot/Assembly workspace bonded to one QuickBooks realm. Almost every table is keyed by `portalId`; almost every service derives `this.user.workspaceId` from the request token and scopes everything to that portal. + +## Common commands + +Package manager is **Yarn 4 (Berry)**, Node **22.14.0** (`.nvmrc`). + +```bash +yarn install # install +yarn dev # Next dev (Turbopack) +yarn build # next build (CI uses build.sh which also runs drizzle-kit migrate) +yarn lint:check # ESLint over src/ and test/ +yarn prettier:check # Prettier check +yarn lint:fix # ESLint --fix +yarn prettier:fix # Prettier write + +# Tests (Vitest, two projects defined in vitest.config.ts) +yarn test # both: unit then integration (groupOrder enforces this) +yarn test:watch # watch +yarn test:coverage # v8 coverage +npx vitest run --project unit # only unit +npx vitest run --project integration # only integration +npx vitest run test/integration/quickbooks/priceCreated/happyPath.test.ts # single file +npx vitest run -t 'happy path' # by test-name pattern + +# Trigger.dev (background task runtime) +yarn trigger:dev # local dev worker +yarn trigger:deploy # deploy tasks + +# DB migrations (Drizzle Kit, schema lives at src/db/schema/) +npx drizzle-kit generate # create new migration from schema changes +npx drizzle-kit migrate # apply pending migrations to DATABASE_URL + +# One-off operational scripts (tsx, see src/cmd/*) +yarn cmd:rename-qb-accounts +yarn cmd:backfill-product-info +yarn cmd:sync-missed-invoices +yarn cmd:sync-missed-products +``` + +Husky `pre-commit` runs `lint-staged` (eslint --fix + prettier --write on `src/**/*.{ts,tsx}`). CI (`.github/workflows/test.yml`) runs `yarn test` on PRs; `.github/workflows/lint.yml` runs lint+prettier on every push. CI assumes the testcontainers Postgres image is available (Docker is preinstalled on `ubuntu-latest`). + +## Architecture + +### Request → handler shape + +Every API route follows the same skeleton: + +``` +src/app/api/// + route.ts # exports { POST/GET } = withErrorHandler(controllerFn); sets maxDuration + .controller.ts # auth + Sentry scope + parse + delegate to service + .service.ts # extends BaseService; orchestrates DB + external APIs +``` + +Controllers call `authenticate(req)` (`src/app/api/core/utils/authenticate.ts`), which reads `?token=…`, asks Copilot to decrypt it, and returns a `User` (`src/app/api/core/models/User.model.ts`). `User` carries `workspaceId` (= portalId), role, and the lazily-attached `qbConnection` (service-item / client-fee refs). + +`withErrorHandler` (`src/app/api/core/utils/withErrorHandler.ts`) is the **only** error path. It maps `ZodError` / `APIError` / `CopilotApiError` / `RetryableError` / Intuit OAuth + Axios errors to HTTP responses and forwards categorized exceptions to Sentry. Don't add try/catch in route handlers — throw and let this wrapper format. + +### BaseService and the DB singleton + +Services extend `BaseService` (`src/app/api/core/services/base.service.ts`), which holds: + +- `this.db` — the **module-level Drizzle singleton** from `src/db/index.ts` (`DBClient.getInstance()`); `casing: 'snake_case'`. +- `this.user` — the authenticated `User` for the request. +- `setTransaction(tx)` / `unsetTransaction()` — swap `this.db` for a transaction handle inside a `db.transaction(...)` callback, then restore. + +**Pitfall (known, see `memory/project_unsetTransaction_bug.md`):** `unsetTransaction()` is sometimes called inside the transaction callback or skipped on error paths — across `BaseService` subclasses this leaves the singleton pointed at a closed tx. When introducing or modifying transactional code, audit that `setTransaction` / `unsetTransaction` are paired in `try/finally` and that nested service calls share the tx handle. + +The DB singleton is also why test helpers (`test/helpers/seed.ts`, `test/helpers/testDb.ts`) import `@/db` directly — see `docs/why-test-helpers-use-the-app-db-singleton.md`. Don't introduce a separate test-only Drizzle client; tests must read what the app writes. + +### Webhook flow (the central path) + +`POST /api/quickbooks/webhook` → `WebhookService.handleWebhookEvent` (`src/app/api/quickbooks/webhook/webhook.service.ts`) is a switch on `payload.eventType` that dispatches to `InvoiceService` / `ProductService` / `PaymentService`. A few things to know before changing it: + +1. **Idempotency is enforced via `qb_sync_logs` claim rows.** `SyncLogService.claimWebhookEvent({ copilotId, entityType, eventType, … })` returns `{ claimed: false }` if a row already exists; handlers exit early. Any new webhook handler must call `claimWebhookEvent` before doing real work or duplicate processing will leak into QBO. +2. **`qb_sync_logs.quickbooks_id` is polymorphic.** Its meaning depends on `(entityType, eventType)` — for `INVOICE/PAID` it stores the QBO **Payment** ID, not the Invoice ID. See `memory/project_qb_sync_logs_semantics.md`. +3. **Pre-claim sleeps for ordering.** `INVOICE_UPDATED` / `INVOICE_VOIDED` / `PAYMENT_SUCCEEDED` sleep before `claimWebhookEvent` so a companion event (e.g., `INVOICE_CREATED`) can claim first. The `delayMs` lives in the handler, not the caller — keep it that way; moving the sleep after the claim re-opens the race. +4. **Setting flags gate handlers.** `PRICE_CREATED` / `PRODUCT_UPDATED` no-op when `createNewProductFlag` is false; `PAYMENT_SUCCEEDED` no-ops when `absorbedFeeFlag` is false or there's no platform-paid fee. Read `qb_settings` via `SettingService` rather than passing flags around. +5. **There's a known TOCTOU race on `claimWebhookEvent`** — accepted, parked, will be addressed with an advisory lock + dedupe job, not a rewrite. See `memory/project_qb_sync_logs_toctou_parked.md`. + +### Token refresh + +QBO access tokens expire in ~1h, refresh tokens in ~100 days. `src/utils/intuitAPI.ts` sends authenticated requests; `src/utils/tokenRefresh.ts` (`getValidQbTokens`) refreshes when stale. The `vercel.json` cron `/api/quickbooks/refresh-tokens` runs daily at 06:00 UTC to keep refresh tokens warm. There's a known silent-401 bug — expired tokens cause `null` returns from `getFetchWithHeader/postFetchWithHeaders`; the planned fix is auto-refresh inside those helpers (design at `docs/intuit-api-token-refresh.md`, summary in `memory/project_intuit_api_token_refresh.md`). + +### Background work + +- **Vercel crons** (`vercel.json`): + - `/api/quickbooks/cron` every 12h — kicks off `processResyncForFailedRecords` (Trigger.dev task) to retry failed sync logs. Auth via `Bearer ${CRON_SECRET}`. + - `/api/quickbooks/refresh-tokens` daily 06:00 UTC. +- **Trigger.dev** tasks live in `src/trigger/` (config at `trigger.config.ts`, runtime: node, default 3 retries, `maxDuration: 3600s`). Sentry source maps are uploaded only when `VERCEL_ENV === 'production'`. + +## Multi-tenancy invariant + +Every `WHERE` clause that touches a portal-scoped table needs `portalId = this.user.workspaceId`. Forgetting this leaks one tenant's data into another. The unique indexes on `qb_sync_logs` and `qb_invoice_sync` (see migrations 20260427100328 / 20260427055352) enforce some of this at the DB level, but most of it is service-layer discipline. + +## Database & schema + +- Drizzle schemas in `src/db/schema/*` registered in `src/db/schema/index.ts`. Relations in `relation.ts`. +- Migrations in `src/db/migrations/` (prefix `supabase`, generated by drizzle-kit). The `init.sql` (20250701) defines all enums; subsequent files alter. +- Custom column helpers in `src/db/helper/column.helper.ts` (`timestamps`) and enum bridge in `drizzle.helper.ts` (`enumToPgEnum`). +- `qb_payments` table exists but is currently unused (reserved for future) — no rows in prod. See `memory/project_qb_payments_unused.md`. +- Type-safe Zod schemas come from `drizzle-zod` (`createInsertSchema` / `createSelectSchema`); reuse those rather than hand-rolling Zod for DB rows. + +## Testing + +- Two Vitest **projects** in `vitest.config.ts` — `unit` (mock-heavy, isolated) and `integration` (real Postgres via testcontainers). Run order is enforced via `sequence.groupOrder` (unit=0, integration=1). +- Integration project is configured **`pool: 'forks'` + `fileParallelism: false` + `isolate: false`** so all integration tests share one Postgres container _and_ one app DB connection. Don't change these without reading `docs/vitest-gotchas.md` and `docs/why-test-helpers-use-the-app-db-singleton.md`. +- `.env.test` is loaded by `test/integration/globalSetup.ts` with `override: true` so a developer's local `.env` can't leak into tests. `DATABASE_URL` is intentionally **not** in `.env.test` — globalSetup sets it from the container's URI before any worker imports `src/config`. +- Module mocks for integration are in `test/integration/setup.ts` — `@/utils/copilotAPI`, `@/utils/intuitAPI`, and `@sentry/nextjs` must be mocked with **explicit factories** (and Intuit/Copilot mock implementations must use `function`, not `=>`, because the code does `new IntuitAPI(...)`). See `docs/vitest-gotchas.md` items 1–3. +- Test helpers in `test/helpers/`: `seed.ts` (`seedHealthyPortal`, `TEST_PORTAL_ID`, etc.), `webhook.ts` (`postWebhook` via `next-test-api-route-handler`), `testDb.ts` (`truncateAllTestTables`). +- Test-data philosophy in `docs/test-data-dos-and-donts.md`: static fixtures for the thing under test, factories with explicit overrides for single-dimension variants, **no faker** in fixtures or assertions. + +## Path aliases + +``` +@/* → src/* +@test/* → test/* +``` + +Configured in `tsconfig.json` and propagated to Vitest via `vite-tsconfig-paths` (per-project in `vitest.config.ts`). + +## Style notes + +- Prettier: single quotes, no semis, trailing comma all (`.prettierrc`). +- ESLint: `next/core-web-vitals` + TypeScript; `prefer-const` and `no-var` are errors; unused-var underscore prefix is exempt; `@typescript-eslint/no-explicit-any` is disabled (the codebase uses `any` deliberately at framework boundaries). +- Tailwind v4 + `copilot-design-system`. UI surface is small (settings dashboard + OAuth callback) — most work happens in the API/service layer. +- The `docs/` folder is **gitignored** (per `.gitignore`) and used for local decision notes — design docs, post-mortems, comparison tables. Save non-trivial tradeoff discussions there rather than in code comments or commit messages. + +## Things to read before non-trivial changes + +- `docs/testcontainers-vs-local-supabase.md` — why integration tests use testcontainers, not the local Supabase stack. +- `docs/why-test-helpers-use-the-app-db-singleton.md` — why test helpers import `@/db` and what would break if you opened a separate client. +- `docs/vitest-gotchas.md` — the five real traps already hit in this project. +- `docs/test-data-dos-and-donts.md` — the test-data rules. +- `docs/intuit-api-token-refresh.md` — design for the silent-401 fix. + +## What this repo doesn't have + +- No design system / shared component library — UI is a thin dashboard, mostly settings forms. +- No GraphQL, no tRPC — plain Next.js Route Handlers + service classes. +- No DI container — `BaseService` reads `db` from a module singleton; tests work _with_ that constraint, not around it. +- No existing CLAUDE.md until this one. + +## Code quality + +- Do not use let unless absolutely necessary. Use const instead. +- Always keep the comments short, on point and easy to understand with easy wordings. This is must. +- Follow DRY, KISS, SOLID, YAGNI principles. + +## Note + +- After a successful implementation, the changes will be reviewed by the team lead and greptileAI in github. \ No newline at end of file From 79517b330d8e12cb752fe1500aee81c401c0b744 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 18 Aug 2026 21:50:17 +0545 Subject: [PATCH 07/11] fix(OUT-4067): drop copilot-node-sdk build patch (package removed) The v4 @assembly-js/node-sdk needs no patch; the old cp step failed the Vercel build because node_modules/copilot-node-sdk no longer exists. Removes the build step, both dead patch scripts, and lib-patches/. Co-Authored-By: Claude Opus 4.8 --- lib-patches/assembly-js-node-sdk.js | 138 ---------------------------- lib-patches/copilot-node-sdk.js | 95 ------------------- package.json | 2 - scripts/build.sh | 15 +-- 4 files changed, 4 insertions(+), 246 deletions(-) delete mode 100644 lib-patches/assembly-js-node-sdk.js delete mode 100644 lib-patches/copilot-node-sdk.js diff --git a/lib-patches/assembly-js-node-sdk.js b/lib-patches/assembly-js-node-sdk.js deleted file mode 100644 index ad80e204..00000000 --- a/lib-patches/assembly-js-node-sdk.js +++ /dev/null @@ -1,138 +0,0 @@ -import { DefaultService as Assembly, OpenAPI } from '../codegen/api' -export { OpenAPI } -import { request as __request } from '../codegen/api/core/request' -import { decryptAES128BitToken, generate128BitKey } from '../utils/crypto' -// SDK version for tracking compatibility -// TODO: Restore dynamic version reading after fixing build -let SDK_VERSION = '3.19.1' -const sdk = Assembly -// Helper functions to check env vars at runtime (supports both new and old names) -function getIsDebug() { - let _a - return !!((_a = process.env.ASSEMBLY_DEBUG) !== null && _a !== void 0 - ? _a - : process.env.COPILOT_DEBUG) -} -function getEnvMode() { - let _a - return (_a = process.env.ASSEMBLY_ENV) !== null && _a !== void 0 - ? _a - : process.env.COPILOT_ENV -} -// Exported for testing purposes only -export function processToken(token) { - try { - const json = JSON.parse(token) - // workspaceId is the only required field - if (!('workspaceId' in json)) { - throw new Error('Missing required field in token payload: workspaceId') - } - // Note: We intentionally do NOT validate that all keys are from a known list. - // This allows the backend to add new fields (like tokenId, expiresAt) without - // breaking older SDK versions. Unknown fields are simply ignored. - const areAllValuesValid = Object.values(json).every( - (val) => typeof val === 'string', - ) - if (!areAllValuesValid) { - throw new Error('Invalid values in token payload.') - } - const result = { - companyId: json.companyId, - clientId: json.clientId, - internalUserId: json.internalUserId, - workspaceId: json.workspaceId, - notificationId: json.notificationId, - baseUrl: json.baseUrl, - tokenId: json.tokenId, - } - return result - } catch (e) { - if (getIsDebug()) { - console.error(e) - } - return null - } -} -// Primary function (new name) -export function assemblyApi({ apiKey, token: tokenString }) { - const isDebug = getIsDebug() - const envMode = getEnvMode() - let key = ['local', '__SECRET_STAGING__'].includes( - envMode !== null && envMode !== void 0 ? envMode : '', - ) - ? apiKey - : undefined - if (isDebug) { - console.log('Debugging the assemblyApi init script.') - console.log({ env: envMode }) - } - if (tokenString) { - if (isDebug) { - console.log({ tokenString, apiKey }) - } - try { - const decipherKey = generate128BitKey(apiKey) - const decryptedPayload = decryptAES128BitToken(decipherKey, tokenString) - if (isDebug) { - console.log('Decrypted Payload:', decryptedPayload) - } - const payload = processToken(decryptedPayload) - if (!payload) { - throw new Error('Invalid token payload.') - } - if (isDebug) { - console.log('Payload:', payload) - } - if (payload.baseUrl) { - OpenAPI.BASE = payload.baseUrl - } - sdk.getTokenPayload = () => new Promise((resolve) => resolve(payload)) - // Build the key: workspaceId/apiKey or workspaceId/apiKey/tokenId if tokenId is present - key = payload.tokenId - ? `${payload.workspaceId}/${apiKey}/${payload.tokenId}` - : `${payload.workspaceId}/${apiKey}` - } catch (error) { - console.error(error) - } - } - if (!key) { - console.warn( - 'We were unable to authorize the SDK. If you are working in a local development environment, set the ASSEMBLY_ENV environment variable to "local" (COPILOT_ENV also works).', - ) - throw new Error('Unable to authorize Assembly SDK.') - } - - // TEMPORARY FIX: suppress sending tokenId to auth header - const [org, project] = key.split('/') - - if (!org || !project) { - throw new Error(`Invalid auth header`) - } - - key = `${org}/${project}` - // disable SDK version to prevent expiry logic from triggering (?) - SDK_VERSION = undefined - // TEMPORARY FIX end - - if (isDebug) { - console.log(`Authorizing with key: ${key}`) - } - - OpenAPI.HEADERS = { - 'X-API-Key': key, - 'X-Assembly-SDK-Version': SDK_VERSION, - } - sdk.sendWebhook = (event, payload) => { - return __request(OpenAPI, { - method: 'POST', - url: '/v1/webhooks/{event}', - path: { event }, - body: payload, - mediaType: 'application/json', - }) - } - return sdk -} -/** @deprecated Use `assemblyApi` instead. Will be removed in v5.0.0. */ -export const copilotApi = assemblyApi -//# sourceMappingURL=init.js.map diff --git a/lib-patches/copilot-node-sdk.js b/lib-patches/copilot-node-sdk.js deleted file mode 100644 index 25b22dbd..00000000 --- a/lib-patches/copilot-node-sdk.js +++ /dev/null @@ -1,95 +0,0 @@ -import { DefaultService as Copilot, OpenAPI } from '../codegen/api' -import { decryptAES128BitToken, generate128BitKey } from '../utils/crypto' -const sdk = Copilot -function processToken(token) { - try { - const json = JSON.parse(token) - // workspaceId is the only required field - if (!('workspaceId' in json)) { - throw new Error('Missing required field in token payload: workspaceId') - } - const areAllKeysValid = Object.keys(json).every((key) => - [ - 'workspaceId', - 'companyId', - 'clientId', - 'internalUserId', - 'notificationId', - // patched below keys for SDK to work and not throw error - 'baseUrl', - 'instanceId', - ].includes(key), - ) - if (!areAllKeysValid) { - throw new Error('Invalid keys in token payload.') - } - const areAllValuesValid = Object.values(json).every( - (val) => typeof val === 'string', - ) - if (!areAllValuesValid) { - throw new Error('Invalid values in token payload.') - } - const result = { - companyId: json.companyId, - clientId: json.clientId, - internalUserId: json.internalUserId, - workspaceId: json.workspaceId, - notificationId: json.notificationId, - } - return result - } catch (e) { - if (process.env.COPILOT_DEBUG) { - console.error(e) - } - return null - } -} -export function copilotApi({ apiKey, token: tokenString }) { - var _a - let key = ['local', '__SECRET_STAGING__'].includes( - (_a = process.env.COPILOT_ENV) !== null && _a !== void 0 ? _a : '', - ) - ? apiKey - : undefined - if (process.env.COPILOT_DEBUG) { - console.log('Debugging the copilotApi init script.') - console.log({ env: process.env.COPILOT_ENV }) - } - if (tokenString) { - if (process.env.COPILOT_DEBUG) { - console.log({ tokenString, apiKey }) - } - try { - const decipherKey = generate128BitKey(apiKey) - const decryptedPayload = decryptAES128BitToken(decipherKey, tokenString) - if (process.env.COPILOT_DEBUG) { - console.log('Decrypted Payload:', decryptedPayload) - } - const payload = processToken(decryptedPayload) - if (!payload) { - throw new Error('Invalid token payload.') - } - if (process.env.COPILOT_DEBUG) { - console.log('Payload:', payload) - } - sdk.getTokenPayload = () => new Promise((resolve) => resolve(payload)) - key = `${payload.workspaceId}/${apiKey}` - } catch (error) { - console.error(error) - } - } - if (!key) { - console.warn( - 'We were unable to authorize the SDK. If you are working in a local development environment, set the COPILOT_ENV environment variable to "local".', - ) - throw new Error('Unable to authorize Copilot SDK.') - } - if (process.env.COPILOT_DEBUG) { - console.log(`Authorizing with key: ${key}`) - } - OpenAPI.HEADERS = { - 'X-API-Key': key, - } - return sdk -} -//# sourceMappingURL=init.js.map diff --git a/package.json b/package.json index b85aa548..0fa49708 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,6 @@ "prepare": "husky", "supabase:dev": "supabase start --ignore-health-check", "cmd:rename-qb-accounts": "tsx src/cmd/renameQbAccount/index.ts", - "patch-assembly-node-sdk": "cp ./lib-patches/assembly-js-node-sdk.js ./node_modules/@assembly-js/node-sdk/dist/api/init.js", - "patch-copilot-node-sdk": "cp ./lib-patches/copilot-node-sdk.js ./node_modules/copilot-node-sdk/dist/api/init.js", "cmd:backfill-product-info": "tsx src/cmd/backfillProductInfo/index.ts", "cmd:sync-missed-invoices": "tsx src/cmd/syncMissedInvoices/index.ts", "cmd:sync-missed-products": "tsx src/cmd/syncMissedProducts/index.ts", diff --git a/scripts/build.sh b/scripts/build.sh index 599f2cc9..48db73e4 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,22 +1,15 @@ #!/usr/bin/env bash set -euo pipefail -# TEMPORARY FIX: suppress sending tokenId to auth header -# yarn patch-assembly-node-sdk - echo "👷 Running build script for environment: ${VERCEL_ENV:-unknown}" -if [ "${VERCEL_ENV:-}" != "production" ]; then - echo "[1/3] Running copilot-node-sdk patch 🧑🏻‍🔧" - yarn patch-copilot-node-sdk -else - echo "[1/3] Skipping copilot-node-sdk patch (production)" -fi +# The @assembly-js/node-sdk (v4) needs no patching — it is per-request scoped +# and carries no client-side token-expiry logic, so no SDK file swap here. -echo "[2/3] Running drizzle-kit migrate" +echo "[1/2] Running drizzle-kit migrate" yarn drizzle-kit migrate -echo "[3/3] Running next build" +echo "[2/2] Running next build" next build echo "🥳 Build completed! 🎉🎉" From 5c12163138eb88968d0a96d5a4064b08ebc97a89 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 19 Aug 2026 15:56:27 +0545 Subject: [PATCH 08/11] refactor(OUT-4067): replace AssemblyTokenPayload class with getAssemblyTokenPayload function Stateless single-method class that was always instantiated just to call it once; a plain function is simpler and drops the pointless `new`. Stays in its own module so the auth-boundary decode remains separately mockable. Co-Authored-By: Claude Opus 4.8 --- src/action/copilot.action.ts | 4 ++-- src/app/api/core/utils/authenticate.ts | 6 ++--- src/app/api/quickbooks/cron/cron.service.ts | 6 ++--- src/cmd/backfillProductInfo/index.ts | 4 ++-- src/cmd/syncMissedInvoices/index.ts | 4 ++-- src/cmd/syncMissedProducts/index.ts | 4 ++-- src/utils/assemblyTokenPayload.ts | 24 ++++++++------------ test/integration/setup.ts | 16 +++++-------- test/unit/utils/assemblyTokenPayload.test.ts | 20 +++++++--------- 9 files changed, 37 insertions(+), 51 deletions(-) diff --git a/src/action/copilot.action.ts b/src/action/copilot.action.ts index b805ee67..fd890c9f 100644 --- a/src/action/copilot.action.ts +++ b/src/action/copilot.action.ts @@ -1,6 +1,6 @@ import { Token } from '@/type/common' -import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' +import { getAssemblyTokenPayload } from '@/utils/assemblyTokenPayload' export async function getTokenPayload(token: string): Promise { - return await new AssemblyTokenPayload().getTokenPayload(token) + return await getAssemblyTokenPayload(token) } diff --git a/src/app/api/core/utils/authenticate.ts b/src/app/api/core/utils/authenticate.ts index 22542b45..2476e528 100644 --- a/src/app/api/core/utils/authenticate.ts +++ b/src/app/api/core/utils/authenticate.ts @@ -1,4 +1,4 @@ -import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' +import { getAssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { NextRequest } from 'next/server' import User from '@/app/api/core/models/User.model' import { z } from 'zod' @@ -8,7 +8,7 @@ import httpStatus from 'http-status' import { withRetry } from '@/app/api/core/utils/withRetry' export const _authenticateWithToken = async (token: string): Promise => { - const tokenPayload = await new AssemblyTokenPayload().getTokenPayload(token) + const tokenPayload = await getAssemblyTokenPayload(token) const payload = TokenSchema.safeParse(tokenPayload) if (!payload.success) { @@ -35,7 +35,7 @@ export const authenticateWithToken = (...args: unknown[]) => * Token parser and authentication util * * `authenticate` takes in the current request, parses the "token" searchParam from it, - * uses `AssemblyTokenPayload` to check if the user token is valid + * uses `getAssemblyTokenPayload` to check if the user token is valid * and finally returns an instance of `User` that is associated with this request */ const authenticate = async (req: NextRequest) => { diff --git a/src/app/api/quickbooks/cron/cron.service.ts b/src/app/api/quickbooks/cron/cron.service.ts index 81192e45..22d4923a 100644 --- a/src/app/api/quickbooks/cron/cron.service.ts +++ b/src/app/api/quickbooks/cron/cron.service.ts @@ -7,7 +7,7 @@ import { copilotAPIKey } from '@/config' import { db } from '@/db' import { QBSyncLog } from '@/db/schema/qbSyncLogs' import { getAllActivePortalConnections } from '@/db/service/token.service' -import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' +import { getAssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { encodePayload } from '@/utils/crypto' import CustomLogger from '@/utils/logger' import * as Sentry from '@sentry/nextjs' @@ -29,9 +29,7 @@ export default class CronService { const token = encodePayload(copilotAPIKey, payload) // check if token is valid or not - const tokenPayload = await new AssemblyTokenPayload().getTokenPayload( - token, - ) + const tokenPayload = await getAssemblyTokenPayload(token) CustomLogger.info({ obj: { copilotApiCronToken: token, tokenPayload }, message: diff --git a/src/cmd/backfillProductInfo/index.ts b/src/cmd/backfillProductInfo/index.ts index 0f0d1ff2..da838af1 100644 --- a/src/cmd/backfillProductInfo/index.ts +++ b/src/cmd/backfillProductInfo/index.ts @@ -4,7 +4,7 @@ import { BackfillProductInfoService } from '@/cmd/backfillProductInfo/backfillPr import { copilotAPIKey } from '@/config' import { PortalConnectionWithSettingType } from '@/db/schema/qbPortalConnections' import { getAllActivePortalConnections } from '@/db/service/token.service' -import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' +import { getAssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { encodePayload } from '@/utils/crypto' import CustomLogger from '@/utils/logger' @@ -54,7 +54,7 @@ async function initiateProcess(connection: PortalConnectionWithSettingType) { } const token = encodePayload(copilotAPIKey, payload) - const tokenPayload = await new AssemblyTokenPayload().getTokenPayload(token) + const tokenPayload = await getAssemblyTokenPayload(token) CustomLogger.info({ obj: { copilotApiCronToken: token, tokenPayload }, message: diff --git a/src/cmd/syncMissedInvoices/index.ts b/src/cmd/syncMissedInvoices/index.ts index f4cd3457..4e1de97a 100644 --- a/src/cmd/syncMissedInvoices/index.ts +++ b/src/cmd/syncMissedInvoices/index.ts @@ -4,7 +4,7 @@ import { SyncMissedInvoicesService } from '@/cmd/syncMissedInvoices/syncMissedIn import { copilotAPIKey } from '@/config' import { PortalConnectionWithSettingType } from '@/db/schema/qbPortalConnections' import { getAllActivePortalConnections } from '@/db/service/token.service' -import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' +import { getAssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { encodePayload } from '@/utils/crypto' import CustomLogger from '@/utils/logger' @@ -53,7 +53,7 @@ async function initiateProcess(connection: PortalConnectionWithSettingType) { } const token = encodePayload(copilotAPIKey, payload) - const tokenPayload = await new AssemblyTokenPayload().getTokenPayload(token) + const tokenPayload = await getAssemblyTokenPayload(token) CustomLogger.info({ obj: { copilotApiCronToken: token, tokenPayload }, message: diff --git a/src/cmd/syncMissedProducts/index.ts b/src/cmd/syncMissedProducts/index.ts index 5a6cd63d..60536005 100644 --- a/src/cmd/syncMissedProducts/index.ts +++ b/src/cmd/syncMissedProducts/index.ts @@ -4,7 +4,7 @@ import { SyncMissedProductsService } from '@/cmd/syncMissedProducts/syncMissedPr import { copilotAPIKey } from '@/config' import { PortalConnectionWithSettingType } from '@/db/schema/qbPortalConnections' import { getAllActivePortalConnections } from '@/db/service/token.service' -import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' +import { getAssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { encodePayload } from '@/utils/crypto' import CustomLogger from '@/utils/logger' @@ -61,7 +61,7 @@ async function initiateProcess(connection: PortalConnectionWithSettingType) { } const token = encodePayload(copilotAPIKey, payload) - const tokenPayload = await new AssemblyTokenPayload().getTokenPayload(token) + const tokenPayload = await getAssemblyTokenPayload(token) CustomLogger.info({ obj: { copilotApiCronToken: token, tokenPayload }, message: diff --git a/src/utils/assemblyTokenPayload.ts b/src/utils/assemblyTokenPayload.ts index 8321aa57..36ccf166 100644 --- a/src/utils/assemblyTokenPayload.ts +++ b/src/utils/assemblyTokenPayload.ts @@ -3,19 +3,15 @@ import { TokenSchema } from '@/type/common' import { assemblyApi } from '@assembly-js/node-sdk' // Decodes a request token into its payload. Kept separate from AssemblyAPI -// (which is workspace-scoped) so the token-scoped SDK stays out of the -// wholesale-mocked client — the auth boundary is the only caller. -export class AssemblyTokenPayload { - async getTokenPayload(token: string) { - const sdk = await assemblyApi({ apiKey: copilotAPIKey, token }) - const getTokenPayload = sdk.getTokenPayload - if (!getTokenPayload) { - console.error( - `AssemblyTokenPayload#getTokenPayload | Could not parse token payload for token ${token}`, - ) - return null - } - - return TokenSchema.parse(await getTokenPayload()) +// (workspace-scoped) so the token-scoped SDK stays out of the wholesale-mocked +// client — the auth boundary is the only caller. +export async function getAssemblyTokenPayload(token: string) { + const sdk = await assemblyApi({ apiKey: copilotAPIKey, token }) + if (!sdk.getTokenPayload) { + console.error( + `getAssemblyTokenPayload | Could not parse token payload for token ${token}`, + ) + return null } + return TokenSchema.parse(await sdk.getTokenPayload()) } diff --git a/test/integration/setup.ts b/test/integration/setup.ts index 2617a48c..7e4f179d 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -33,18 +33,14 @@ vi.mock('@/utils/assemblyAPI', () => ({ AssemblyAPI: g.__qbsync_test_mocks!.AssemblyAPI!, })) -// Token decode lives in a separate module (AssemblyTokenPayload) so it stays -// out of the wholesale-mocked AssemblyAPI. Plain class — not vi.fn — so it +// Token decode lives in a separate module (getAssemblyTokenPayload) so it stays +// out of the wholesale-mocked AssemblyAPI. Plain fn — not vi.fn — so it // survives the clearAllMocks/restoreAllMocks that per-test setup helpers call. vi.mock('@/utils/assemblyTokenPayload', () => ({ - AssemblyTokenPayload: class { - async getTokenPayload() { - return { - workspaceId: TEST_PORTAL_ID, - internalUserId: TEST_INTERNAL_USER_ID, - } - } - }, + getAssemblyTokenPayload: async () => ({ + workspaceId: TEST_PORTAL_ID, + internalUserId: TEST_INTERNAL_USER_ID, + }), })) vi.mock('@/utils/intuitAPI', () => ({ diff --git a/test/unit/utils/assemblyTokenPayload.test.ts b/test/unit/utils/assemblyTokenPayload.test.ts index 3842295a..e7e2af85 100644 --- a/test/unit/utils/assemblyTokenPayload.test.ts +++ b/test/unit/utils/assemblyTokenPayload.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // fresh, per-call client whose getTokenPayload() resolves the payload derived // from THIS call's token (captured in a closure), never shared global state. // A staggered delay inside getTokenPayload forces concurrent calls to resolve -// out of order — so any cross-call state leak in AssemblyTokenPayload would +// out of order — so any cross-call state leak in getAssemblyTokenPayload would // surface as a mismatched workspaceId. vi.mock('@assembly-js/node-sdk', () => ({ assemblyApi: vi.fn(async ({ token }: { token: string }) => { @@ -22,22 +22,20 @@ vi.mock('@assembly-js/node-sdk', () => ({ })) import { assemblyApi } from '@assembly-js/node-sdk' -import { AssemblyTokenPayload } from '@/utils/assemblyTokenPayload' +import { getAssemblyTokenPayload } from '@/utils/assemblyTokenPayload' // In this test a "token" is just a JSON string carrying the workspace it belongs // to and how long its decode should take. const makeToken = (workspaceId: string, delayMs: number) => JSON.stringify({ workspaceId, delayMs }) -describe('AssemblyTokenPayload.getTokenPayload — concurrency isolation', () => { +describe('getAssemblyTokenPayload — concurrency isolation', () => { beforeEach(() => { vi.clearAllMocks() }) it('returns the matching workspaceId for a single call', async () => { - const payload = await new AssemblyTokenPayload().getTokenPayload( - makeToken('ws-solo', 0), - ) + const payload = await getAssemblyTokenPayload(makeToken('ws-solo', 0)) expect(payload?.workspaceId).toBe('ws-solo') }) @@ -50,9 +48,7 @@ describe('AssemblyTokenPayload.getTokenPayload — concurrency isolation', () => const results = await Promise.all( workspaceIds.map((workspaceId, i) => - new AssemblyTokenPayload().getTokenPayload( - makeToken(workspaceId, (count - i) * 2), - ), + getAssemblyTokenPayload(makeToken(workspaceId, (count - i) * 2)), ), ) @@ -64,9 +60,9 @@ describe('AssemblyTokenPayload.getTokenPayload — concurrency isolation', () => it('builds one independent SDK per call (no shared client)', async () => { await Promise.all([ - new AssemblyTokenPayload().getTokenPayload(makeToken('ws-a', 6)), - new AssemblyTokenPayload().getTokenPayload(makeToken('ws-b', 3)), - new AssemblyTokenPayload().getTokenPayload(makeToken('ws-c', 0)), + getAssemblyTokenPayload(makeToken('ws-a', 6)), + getAssemblyTokenPayload(makeToken('ws-b', 3)), + getAssemblyTokenPayload(makeToken('ws-c', 0)), ]) // assemblyApi is invoked once per call, each with that call's own token. From 22ba1780a1fe611a6452d72e03071ecf9891411f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 19 Aug 2026 16:21:05 +0545 Subject: [PATCH 09/11] refactor(OUT-4067): remove redundant copilot.action wrapper getTokenPayload was a one-line pass-through to getAssemblyTokenPayload with no 'use server' boundary; Home (a server component) now calls the helper directly. Co-Authored-By: Claude Opus 4.8 --- src/action/copilot.action.ts | 6 ------ src/app/(home)/Home.tsx | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 src/action/copilot.action.ts diff --git a/src/action/copilot.action.ts b/src/action/copilot.action.ts deleted file mode 100644 index fd890c9f..00000000 --- a/src/action/copilot.action.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Token } from '@/type/common' -import { getAssemblyTokenPayload } from '@/utils/assemblyTokenPayload' - -export async function getTokenPayload(token: string): Promise { - return await getAssemblyTokenPayload(token) -} diff --git a/src/app/(home)/Home.tsx b/src/app/(home)/Home.tsx index e77ecfef..ab4ea09f 100644 --- a/src/app/(home)/Home.tsx +++ b/src/app/(home)/Home.tsx @@ -1,4 +1,3 @@ -import { getTokenPayload } from '@/action/copilot.action' import { checkPortalConnection, reconnectIfCta, @@ -9,6 +8,7 @@ import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service' import { AppProvider } from '@/app/context/AppContext' import { SilentError } from '@/components/template/SilentError' import { getWorkspaceInfo } from '@/db/service/token.service' +import { getAssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { z } from 'zod' export default async function Main({ @@ -27,7 +27,7 @@ export default async function Main({ return } - const tokenPayload = await getTokenPayload(token) + const tokenPayload = await getAssemblyTokenPayload(token) if (!tokenPayload) { return } From 47f96041996eb68acf8c52d341973b65a6a01cff Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 19 Aug 2026 16:21:07 +0545 Subject: [PATCH 10/11] refactor(OUT-4067): drop redundant token re-parse in authenticate getAssemblyTokenPayload already validates against TokenSchema; use the Token|null result directly instead of safeParse-ing it again. Co-Authored-By: Claude Opus 4.8 --- src/app/api/core/utils/authenticate.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/app/api/core/utils/authenticate.ts b/src/app/api/core/utils/authenticate.ts index 2476e528..ed97615c 100644 --- a/src/app/api/core/utils/authenticate.ts +++ b/src/app/api/core/utils/authenticate.ts @@ -2,23 +2,21 @@ import { getAssemblyTokenPayload } from '@/utils/assemblyTokenPayload' import { NextRequest } from 'next/server' import User from '@/app/api/core/models/User.model' import { z } from 'zod' -import { TokenSchema } from '@/type/common' import APIError from '@/app/api/core/exceptions/api' import httpStatus from 'http-status' import { withRetry } from '@/app/api/core/utils/withRetry' export const _authenticateWithToken = async (token: string): Promise => { const tokenPayload = await getAssemblyTokenPayload(token) - const payload = TokenSchema.safeParse(tokenPayload) - if (!payload.success) { + if (!tokenPayload) { throw new APIError(httpStatus.UNAUTHORIZED, 'Failed to authenticate token') } // Access to IU and webhook events. if ( - !payload.data.internalUserId && - (payload.data.clientId || payload.data.companyId) + !tokenPayload.internalUserId && + (tokenPayload.clientId || tokenPayload.companyId) ) { throw new APIError( httpStatus.UNAUTHORIZED, @@ -26,7 +24,7 @@ export const _authenticateWithToken = async (token: string): Promise => { ) } - return new User(token, payload.data) + return new User(token, tokenPayload) } export const authenticateWithToken = (...args: unknown[]) => withRetry(_authenticateWithToken, args) From 4e72c00747a4b2d243a253222e98464cb0d9ace4 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 19 Aug 2026 16:21:09 +0545 Subject: [PATCH 11/11] refactor(OUT-4067): make AssemblyAPI.getSDK private, drop redundant await Co-Authored-By: Claude Opus 4.8 --- src/utils/assemblyAPI.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/assemblyAPI.ts b/src/utils/assemblyAPI.ts index 80c2731c..59730f69 100644 --- a/src/utils/assemblyAPI.ts +++ b/src/utils/assemblyAPI.ts @@ -85,8 +85,8 @@ export class AssemblyAPI { return await resp.json() } - async getSDK() { - return await this.assembly + private getSDK() { + return this.assembly } // NOTE: Any method prefixed with _ is a API method that doesn't implement retry & delay