From 8f63a1befc46ab2ef1a9353b16d7d5689a33cddf Mon Sep 17 00:00:00 2001 From: Prios Shrestha <30313649+priosshrsth@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:33:34 +0545 Subject: [PATCH 1/2] OUT-4027 | Fix 500 when deleting a task whose label row is missing (#1397) deleteLabel passed `id: currentLabel?.id` straight into label.delete, so when findFirst matched nothing Prisma got `{ id: undefined }` and threw PrismaClientValidationError, failing the whole delete transaction. Return early instead. --- .../label-mapping.service.test.ts | 44 +++++++++++++++++++ .../label-mapping/label-mapping.service.ts | 3 +- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/app/api/label-mapping/label-mapping.service.test.ts diff --git a/src/app/api/label-mapping/label-mapping.service.test.ts b/src/app/api/label-mapping/label-mapping.service.test.ts new file mode 100644 index 000000000..24a195ede --- /dev/null +++ b/src/app/api/label-mapping/label-mapping.service.test.ts @@ -0,0 +1,44 @@ +const mockLabelFindFirst = jest.fn() +const mockLabelDelete = jest.fn() + +jest.mock('@/lib/db', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + label: { findFirst: mockLabelFindFirst, delete: mockLabelDelete }, + }), + }, +})) + +jest.mock('@/utils/CopilotAPI', () => ({ CopilotAPI: jest.fn() })) + +import { LabelMappingService } from '@api/label-mapping/label-mapping.service' +import User from '@api/core/models/User.model' +import { UserRole } from '@api/core/types/user' + +const user = { + workspaceId: 'ws-1', + role: UserRole.IU, + internalUserId: 'iu-1', + token: 'token', +} as unknown as User + +describe('LabelMappingService#deleteLabel', () => { + beforeEach(() => jest.clearAllMocks()) + + it('deletes the matching label row', async () => { + mockLabelFindFirst.mockResolvedValue({ id: 'label-1' }) + + await new LabelMappingService(user).deleteLabel('ASS10-009') + + expect(mockLabelDelete).toHaveBeenCalledWith({ where: { id: 'label-1' } }) + }) + + it('no-ops when the label row is already gone', async () => { + mockLabelFindFirst.mockResolvedValue(null) + + await new LabelMappingService(user).deleteLabel('ASS10-009') + + expect(mockLabelDelete).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/api/label-mapping/label-mapping.service.ts b/src/app/api/label-mapping/label-mapping.service.ts index 42007a03c..b0d9c2abd 100644 --- a/src/app/api/label-mapping/label-mapping.service.ts +++ b/src/app/api/label-mapping/label-mapping.service.ts @@ -175,9 +175,10 @@ export class LabelMappingService extends BaseService { label, }, }) + if (!currentLabel) return await this.db.label.delete({ where: { - id: currentLabel?.id, + id: currentLabel.id, }, }) } From ffd49455c18f6a93b3820345408638b3a2dc2362 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 14:12:20 +0000 Subject: [PATCH 2/2] POR-22617: Retry client list pagination when Copilot cursor is invalid When fetching large client lists (>5000), CopilotAPI paginates via nextToken. DynamoDB can reject a stale or invalidated cursor with ValidationException (starting key is invalid), which surfaces in Tasks Sentry during assignee load. Retry once without the stale token for single-page requests, and restart batched pagination from the first page when a later-page cursor fails. Co-authored-by: Neil Raina --- src/utils/CopilotAPI.getClients.test.ts | 74 +++++++++++++++++++++++++ src/utils/CopilotAPI.ts | 63 +++++++++++++++------ src/utils/copilotError.test.ts | 15 +++++ src/utils/copilotError.ts | 5 ++ 4 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 src/utils/CopilotAPI.getClients.test.ts create mode 100644 src/utils/copilotError.test.ts diff --git a/src/utils/CopilotAPI.getClients.test.ts b/src/utils/CopilotAPI.getClients.test.ts new file mode 100644 index 000000000..5a62df2f6 --- /dev/null +++ b/src/utils/CopilotAPI.getClients.test.ts @@ -0,0 +1,74 @@ +import { MAX_LIMIT_CLIENT_COUNT } from '@/constants/users' +import { ClientResponse } from '@/types/common' +import { CopilotAPI } from '@/utils/CopilotAPI' + +const mockListClients = jest.fn() + +jest.mock('copilot-node-sdk', () => ({ + copilotApi: () => ({ + listClients: mockListClients, + }), +})) + +jest.mock('@/app/api/core/utils/withRetry', () => ({ + withRetry: (fn: (...args: unknown[]) => unknown, args: unknown[]) => fn(...args), + RETRY_404_ENABLED: false, +})) + +const invalidStartingKeyError = new Error( + 'Failed to list clients: ValidationException: The provided starting key is invalid', +) + +const buildClient = (id: string): ClientResponse => ({ + id, + givenName: 'Test', + familyName: 'Client', + email: `${id}@example.com`, + companyId: '00000000-0000-4000-8000-000000000001', + status: 'active', + avatarImageUrl: null, + createdAt: '2026-01-01T00:00:00.000Z', +}) + +describe('CopilotAPI#getClients pagination', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('retries without a stale nextToken for single-page requests', async () => { + mockListClients + .mockRejectedValueOnce(invalidStartingKeyError) + .mockResolvedValueOnce({ data: [buildClient('client-1')] }) + + const copilot = new CopilotAPI('token') + const response = await copilot.getClients({ limit: 100, nextToken: 'stale-token' }) + + expect(response.data).toEqual([buildClient('client-1')]) + expect(mockListClients).toHaveBeenCalledTimes(2) + expect(mockListClients.mock.calls[0][0]).toEqual({ limit: 100, nextToken: 'stale-token' }) + expect(mockListClients.mock.calls[1][0]).toEqual({ limit: 100 }) + }) + + it('retries batched pagination from the first page when a later page cursor is invalid', async () => { + const firstPageClients = Array.from({ length: MAX_LIMIT_CLIENT_COUNT }, (_, index) => + buildClient(`client-${index}`), + ) + const secondPageClients = [buildClient('client-next-page')] + + mockListClients + .mockResolvedValueOnce({ data: firstPageClients, nextToken: 'page-2-token' }) + .mockRejectedValueOnce(invalidStartingKeyError) + .mockResolvedValueOnce({ data: firstPageClients, nextToken: 'page-2-token' }) + .mockResolvedValueOnce({ data: secondPageClients }) + + const copilot = new CopilotAPI('token') + const response = await copilot.getClients({ limit: MAX_LIMIT_CLIENT_COUNT + 1 }) + + expect(response.data).toHaveLength(MAX_LIMIT_CLIENT_COUNT + 1) + expect(mockListClients).toHaveBeenCalledTimes(4) + expect(mockListClients.mock.calls[2][0]).toEqual({ + limit: MAX_LIMIT_CLIENT_COUNT, + nextToken: undefined, + }) + }) +}) diff --git a/src/utils/CopilotAPI.ts b/src/utils/CopilotAPI.ts index eed475e79..df5a94e54 100644 --- a/src/utils/CopilotAPI.ts +++ b/src/utils/CopilotAPI.ts @@ -45,6 +45,7 @@ import { DISPATCHABLE_EVENT } from '@/types/webhook' import Bottleneck from 'bottleneck' import type { CopilotAPI as SDK } from 'copilot-node-sdk' import { copilotApi } from 'copilot-node-sdk' +import { isInvalidPaginationCursorError } from '@/utils/copilotError' import { cache } from 'react' import { z } from 'zod' @@ -161,28 +162,56 @@ export class CopilotAPI { console.info('CopilotAPI#_getClients', this.token) const maxLimit = MAX_LIMIT_CLIENT_COUNT const requestedLimit = args.limit || maxLimit - let clients: ClientResponse[] = [] - let nextToken: string | undefined = undefined + + const listClientsOnce = async (listArgs: CopilotListArgs & { companyId?: string }) => { + return ClientsResponseSchema.parse(await this.copilot.listClients(listArgs)) + } + + const listClientsWithCursorRetry = async (listArgs: CopilotListArgs & { companyId?: string }) => { + try { + return await listClientsOnce(listArgs) + } catch (error) { + if (!isInvalidPaginationCursorError(error) || !listArgs.nextToken) throw error + + const { nextToken: _staleToken, ...listArgsWithoutToken } = listArgs + return await listClientsOnce(listArgsWithoutToken) + } + } if (requestedLimit <= maxLimit) { - return ClientsResponseSchema.parse(await this.copilot.listClients(args)) + return listClientsWithCursorRetry(args) } - //fetching client data in batches of MAX_LIMIT_CLIENT_COUNT instead of fetching it as a whole. - do { - const remaining = requestedLimit - clients.length - const fetchLimit = Math.min(maxLimit, remaining) - const response = await this.copilot.listClients({ - ...args, - limit: fetchLimit, - nextToken, - }) - const parsedData = ClientsResponseSchema.parse(response)?.data ?? [] - clients.push(...parsedData) - nextToken = response?.nextToken || undefined - } while (nextToken && clients.length < requestedLimit) + const fetchAllPages = async (): Promise => { + const clients: ClientResponse[] = [] + let nextToken: string | undefined = undefined + const { nextToken: _callerToken, ...baseArgs } = args + + do { + const remaining = requestedLimit - clients.length + const fetchLimit = Math.min(maxLimit, remaining) + const response = await this.copilot.listClients({ + ...baseArgs, + limit: fetchLimit, + nextToken, + }) + const parsedData = ClientsResponseSchema.parse(response)?.data ?? [] + clients.push(...parsedData) + nextToken = response?.nextToken || undefined + } while (nextToken && clients.length < requestedLimit) + + return clients + } - return ClientsResponseSchema.parse({ data: clients }) + try { + const clients = await fetchAllPages() + return ClientsResponseSchema.parse({ data: clients }) + } catch (error) { + if (!isInvalidPaginationCursorError(error)) throw error + + const clients = await fetchAllPages() + return ClientsResponseSchema.parse({ data: clients }) + } } async _updateClient(id: string, requestBody: ClientRequest): Promise { diff --git a/src/utils/copilotError.test.ts b/src/utils/copilotError.test.ts new file mode 100644 index 000000000..2c2ee621e --- /dev/null +++ b/src/utils/copilotError.test.ts @@ -0,0 +1,15 @@ +import { isInvalidPaginationCursorError } from '@/utils/copilotError' + +describe('isInvalidPaginationCursorError', () => { + it('matches DynamoDB invalid starting key errors', () => { + expect( + isInvalidPaginationCursorError( + new Error('Failed to list clients: ValidationException: The provided starting key is invalid'), + ), + ).toBe(true) + }) + + it('does not match unrelated errors', () => { + expect(isInvalidPaginationCursorError(new Error('Unauthorized'))).toBe(false) + }) +}) diff --git a/src/utils/copilotError.ts b/src/utils/copilotError.ts index 4e31568fb..f67b444b2 100644 --- a/src/utils/copilotError.ts +++ b/src/utils/copilotError.ts @@ -5,3 +5,8 @@ export const isMessagableError = (e: unknown): e is MessagableError => { typeof e === 'object' && e !== null && 'message' in e && (!('body' in e) || typeof (e as any).body?.message === 'string') ) } + +export const isInvalidPaginationCursorError = (error: unknown): boolean => { + const message = error instanceof Error ? error.message : String(error) + return /starting key is invalid/i.test(message) +}