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 1327cf7f3da758f8cca45c4378021e5a8e7edcd0 Mon Sep 17 00:00:00 2001 From: Prios Shrestha <30313649+priosshrsth@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:46:01 +0545 Subject: [PATCH 2/2] OUT-4093 | Stop reading localStorage in the assignee cache (#1434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * OUT-4093 | Remove the localStorage→localForage assignee migration --- src/app/_cache/AssigneeCacheGetter.tsx | 3 +- src/app/_cache/forageStorage.test.ts | 45 ++++++++++++++++++++++++++ src/app/_cache/forageStorage.ts | 35 ++------------------ 3 files changed, 49 insertions(+), 34 deletions(-) create mode 100644 src/app/_cache/forageStorage.test.ts diff --git a/src/app/_cache/AssigneeCacheGetter.tsx b/src/app/_cache/AssigneeCacheGetter.tsx index ccb7cda7b..1d665706f 100644 --- a/src/app/_cache/AssigneeCacheGetter.tsx +++ b/src/app/_cache/AssigneeCacheGetter.tsx @@ -3,7 +3,7 @@ import { setAssigneeList } from '@/redux/features/taskBoardSlice' import store from '@/redux/store' import { useEffect } from 'react' -import { getAssignees, migrateAssignees } from '@/app/_cache/forageStorage' +import { getAssignees } from '@/app/_cache/forageStorage' interface ClientAssigneeCacheGetterProps { lookupKey: string @@ -12,7 +12,6 @@ interface ClientAssigneeCacheGetterProps { export const AssigneeCacheGetter = ({ lookupKey }: ClientAssigneeCacheGetterProps) => { useEffect(() => { const run = async () => { - await migrateAssignees(lookupKey) //migrate from localStorage to localForage if required. Remember to remove this after a while. const assignee = await getAssignees(lookupKey) if (assignee.length) { store.dispatch(setAssigneeList(assignee)) diff --git a/src/app/_cache/forageStorage.test.ts b/src/app/_cache/forageStorage.test.ts new file mode 100644 index 000000000..dbfe00100 --- /dev/null +++ b/src/app/_cache/forageStorage.test.ts @@ -0,0 +1,45 @@ +const mockGetItem = jest.fn() +const mockSetItem = jest.fn() + +jest.mock('localforage', () => ({ + __esModule: true, + default: { + config: jest.fn(), + getItem: (...args: unknown[]) => mockGetItem(...args), + setItem: (...args: unknown[]) => mockSetItem(...args), + }, +})) + +import { getAssignees, setAssignees } from '@/app/_cache/forageStorage' + +const denied = () => new DOMException('Access is denied for this document.', 'SecurityError') + +describe('assignee cache', () => { + beforeAll(() => { + globalThis.window = {} as Window & typeof globalThis + }) + afterAll(() => { + delete (globalThis as { window?: unknown }).window + }) + beforeEach(() => jest.clearAllMocks()) + + it('returns an empty list when storage access is denied', async () => { + mockGetItem.mockRejectedValue(denied()) + + await expect(getAssignees('lookup-key')).resolves.toEqual([]) + expect(mockGetItem).toHaveBeenCalledWith('assignees.lookup-key') + }) + + it('swallows write failures when storage access is denied', async () => { + mockSetItem.mockRejectedValue(denied()) + + await expect(setAssignees('lookup-key', [])).resolves.toBeUndefined() + expect(mockSetItem).toHaveBeenCalledWith('assignees.lookup-key', []) + }) + + it('returns an empty list when nothing is cached', async () => { + mockGetItem.mockResolvedValue(null) + + await expect(getAssignees('lookup-key')).resolves.toEqual([]) + }) +}) diff --git a/src/app/_cache/forageStorage.ts b/src/app/_cache/forageStorage.ts index e8baf6391..e4c23abe0 100644 --- a/src/app/_cache/forageStorage.ts +++ b/src/app/_cache/forageStorage.ts @@ -8,52 +8,23 @@ localforage.config({ storeName: 'assignees', }) -export async function migrateAssignees(lookupKey: string) { - const lKey = `assignees.${lookupKey}` - const existing = localStorage.getItem(lKey) - - if (existing) { - try { - const parsed = JSON.parse(existing) - await localforage.setItem(lKey, parsed) - localStorage.removeItem(lKey) - } catch (err) { - console.error('Migration failed', err) - } - } -} //a utility function to migrate existing assignee data from localStorage to localForage - export async function getAssignees(lookupKey: string): Promise { if (typeof window === 'undefined') return [] try { - if (!(await document.hasStorageAccess())) { - console.info('Browswer has no storage access') - await document.requestStorageAccess() - } - return (await localforage.getItem(`assignees.${lookupKey}`)) ?? [] } catch (error: unknown) { - console.error( - "Storage access not granted. Under Chrome's Settings > Privacy and Security, make sure 'Third-party cookies' is allowed.", - ) + console.info('Assignee cache unavailable, falling back to network', error) return [] } } -export async function setAssignees(lookupKey: string, value: any) { +export async function setAssignees(lookupKey: string, value: IAssigneeCombined[]) { if (typeof window === 'undefined') return try { - if (!(await document.hasStorageAccess())) { - console.info('Browswer has no storage access') - await document.requestStorageAccess() - } - return await localforage.setItem(`assignees.${lookupKey}`, value) } catch (error: unknown) { - console.error( - "Storage access not granted. Under Chrome's Settings > Privacy and Security, make sure 'Third-party cookies' is allowed.", - ) + console.info('Assignee cache write skipped, storage unavailable', error) } }