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/3] 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/3] 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) } } From 92b312fe4cbfd83fbffba411bbf961ca5c78c95f Mon Sep 17 00:00:00 2001 From: priosshrsth Date: Wed, 26 Aug 2026 03:36:45 +0000 Subject: [PATCH 3/3] OUT-4109 | Handle SWR mutate rejections instead of letting them reach Sentry Two rejection paths per component: the optimistic mutate() was never awaited, so its rejection escaped the surrounding try/catch, and debounceMutate() is fire-and-forget. Both surface as unhandled rejections when fetcher() throws. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y4tF84tzJW7Bo1DehsDX22 --- src/app/configure-tasks-app/ui/Subtemplates.tsx | 7 ++++--- src/app/detail/ui/ActivityWrapper.tsx | 5 +++-- src/app/detail/ui/Subtasks.tsx | 7 ++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/app/configure-tasks-app/ui/Subtemplates.tsx b/src/app/configure-tasks-app/ui/Subtemplates.tsx index f1742287a..dfae06283 100644 --- a/src/app/configure-tasks-app/ui/Subtemplates.tsx +++ b/src/app/configure-tasks-app/ui/Subtemplates.tsx @@ -53,7 +53,8 @@ export const Subtemplates = ({ template_id, token }: { template_id: string; toke const { mutate } = useSWRConfig() - const _debounceMutate = async (cacheKey: string) => await mutate(cacheKey) + const _debounceMutate = (cacheKey: string) => + mutate(cacheKey).catch((error) => console.error('Failed to revalidate subtemplates:', error)) const debounceMutate = useDebounce(_debounceMutate, 200) useEffect(() => { @@ -68,7 +69,7 @@ export const Subtemplates = ({ template_id, token }: { template_id: string; toke debounceMutate(cacheKey) }, [activeTemplate?.subTaskTemplates]) - const handleSubtemplateCreation = (payload: CreateTemplateRequest) => { + const handleSubtemplateCreation = async (payload: CreateTemplateRequest) => { const tempId = generateRandomString('temp-template') setOptimisticUpdates((prev) => [ ...prev, @@ -90,7 +91,7 @@ export const Subtemplates = ({ template_id, token }: { template_id: string; toke const optimisticData = sortTaskByDescendingOrder([...currentSubtemplates, tempSubtemplate]) try { - mutate( + await mutate( cacheKey, async () => { const subTask = await createSubTemplate(token, template_id, payload) diff --git a/src/app/detail/ui/ActivityWrapper.tsx b/src/app/detail/ui/ActivityWrapper.tsx index 8bd1b1ff9..672c2c2a9 100644 --- a/src/app/detail/ui/ActivityWrapper.tsx +++ b/src/app/detail/ui/ActivityWrapper.tsx @@ -55,7 +55,8 @@ export const ActivityWrapper = ({ useScrollToElement('commentId') - const _debounceMutate = async (cacheKey: string) => await mutate(cacheKey) + const _debounceMutate = (cacheKey: string) => + mutate(cacheKey).catch((error) => console.error('Failed to revalidate activity logs:', error)) const debounceMutate = useDebounce(_debounceMutate, 300) const shouldRefetchRef = useRef(true) //preventing double fetching from comment apis. Due to optimistic update revalidation, we are already fetching logs there. So no need to refetch in case for comment creation and deletion. @@ -102,7 +103,7 @@ export const ActivityWrapper = ({ const optimisticData = getOptimisticData(postCommentPayload, activities.data, tempLog) try { - mutate( + await mutate( cacheKey, async () => { shouldRefetchRef.current = false diff --git a/src/app/detail/ui/Subtasks.tsx b/src/app/detail/ui/Subtasks.tsx index 51c45cf86..579a5311e 100644 --- a/src/app/detail/ui/Subtasks.tsx +++ b/src/app/detail/ui/Subtasks.tsx @@ -67,7 +67,8 @@ export const Subtasks = ({ const { mutate } = useSWRConfig() - const _debounceMutate = async (cacheKey: string) => await mutate(cacheKey) + const _debounceMutate = (cacheKey: string) => + mutate(cacheKey).catch((error) => console.error('Failed to revalidate subtasks:', error)) const debounceMutate = useDebounce(_debounceMutate, 200) useEffect(() => { @@ -84,7 +85,7 @@ export const Subtasks = ({ setLastUpdated(activeTask?.lastSubtaskUpdated) }, [activeTask?.lastSubtaskUpdated]) - const handleSubTaskCreation = (payload: CreateTaskRequest) => { + const handleSubTaskCreation = async (payload: CreateTaskRequest) => { const tempId = generateRandomString('temp-task') setOptimisticUpdates((prev) => [ ...prev, @@ -104,7 +105,7 @@ export const Subtasks = ({ ) const optimisticData = subTasks?.tasks ? sortSubtasksByPriority([...subTasks.tasks, tempSubtask]) : [tempSubtask] try { - mutate( + await mutate( cacheKey, async () => { const subTask = await handleCreate(token, payload, { disableSubtaskTemplates: true })