Repository: copilot-platforms/tasks-app
Pull request #750: OUT-1625 | Realtime issue when parentTask is updated on disjoint tasks.
Author: login: rrojan
Created: 2025-04-16T13:48:52Z | Merged: 2025-04-18T09:59:16Z

## Changes

- [x] Update realtimeUpdates function to remove child tasks if no longer disjoint
- [x] Update subtask update handler so we are future-proof when we re-introduce more than 1 level of subtasking

## Testing Criteria

- [x] Screencast



https://github.com/user-attachments/assets/1b7190f2-9fe6-4c22-864c-401170ba5693





Diff:
diff --git a/src/app/(home)/actions.ts b/src/app/(home)/actions.ts
index 687eac2c7..1324cfd91 100644
--- a/src/app/(home)/actions.ts
+++ b/src/app/(home)/actions.ts
@@ -3,6 +3,8 @@ import { apiUrl } from '@/config'
 import { CreateAttachmentRequest } from '@/types/dto/attachments.dto'
 import { CreateTaskRequest, UpdateTaskRequest } from '@/types/dto/tasks.dto'
 import { CreateViewSettingsDTO } from '@/types/dto/viewSettings.dto'
+import { AssigneeType } from '@prisma/client'
+import { revalidateTag } from 'next/cache'
 
 export const handleCreate = async (token: string, payload: CreateTaskRequest) => {
   try {
@@ -64,3 +66,11 @@ export const getSignedUrlFile = async (token: string, filePath: string) => {
   const data = await res.json()
   return data.signedUrl
 }
+
+export const revalidateAllTasks = async (userRole: AssigneeType) => {
+  if (userRole === AssigneeType.client) {
+    revalidateTag('getAllTasks-client')
+  } else {
+    revalidateTag('getTasks')
+  }
+}
diff --git a/src/app/_fetchers/AllTasksFetcher.tsx b/src/app/_fetchers/AllTasksFetcher.tsx
index aa6b3415f..625a22fcf 100644
--- a/src/app/_fetchers/AllTasksFetcher.tsx
+++ b/src/app/_fetchers/AllTasksFetcher.tsx
@@ -2,22 +2,12 @@ export const fetchCache = 'force-no-store'
 
 import { apiUrl } from '@/config'
 import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate'
-import { AccessibleTasksResponse } from '@/types/dto/tasks.dto'
+import { TaskResponse } from '@/types/dto/tasks.dto'
 import { PropsWithToken } from '@/types/interfaces'
 import { PropsWithChildren } from 'react'
 
-const getAllAccessibleTasks = async (token: string): Promise<AccessibleTasksResponse[]> => {
-  const select: (keyof AccessibleTasksResponse)[] = [
-    'id',
-    'assigneeId',
-    'assigneeType',
-    'title',
-    'body',
-    'parentId',
-    'label',
-  ]
-
-  const res = await fetch(`${apiUrl}/api/tasks?token=${token}&all=1&select=${select.join(',')}`)
+const getAllAccessibleTasks = async (token: string): Promise<TaskResponse[]> => {
+  const res = await fetch(`${apiUrl}/api/tasks?token=${token}&all=1}`)
   const { tasks } = await res.json()
   return tasks
 }
diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts
index e894a6288..15b0e0e64 100644
--- a/src/app/api/tasks/tasks.service.ts
+++ b/src/app/api/tasks/tasks.service.ts
@@ -107,19 +107,19 @@ export class TasksService extends BaseService {
 
     let tasks: Task[] | (Task & { workflowState: WorkflowState })[]
 
-    if (!select) {
+    if (select) {
+      // @ts-expect-error workaround to support ModelSelectInput
       tasks = await this.db.task.findMany({
         where,
         orderBy,
-        relationLoadStrategy: 'join',
-        include: { workflowState: true },
+        select,
       })
     } else {
-      // @ts-expect-error workaround to support ModelSelectInput
       tasks = await this.db.task.findMany({
         where,
         orderBy,
-        select,
+        relationLoadStrategy: 'join',
+        include: { workflowState: true },
       })
     }
 
diff --git a/src/hoc/ClientSideStateUpdate.tsx b/src/hoc/ClientSideStateUpdate.tsx
index 526c47ad3..85dc9f95a 100644
--- a/src/hoc/ClientSideStateUpdate.tsx
+++ b/src/hoc/ClientSideStateUpdate.tsx
@@ -18,7 +18,7 @@ import { setAssigneeSuggestion, setExpandedComments } from '@/redux/features/tas
 import { setTemplates } from '@/redux/features/templateSlice'
 import store from '@/redux/store'
 import { Token } from '@/types/common'
-import { AccessibleTasksResponse, TaskResponse } from '@/types/dto/tasks.dto'
+import { TaskResponse } from '@/types/dto/tasks.dto'
 import { CreateViewSettingsDTO } from '@/types/dto/viewSettings.dto'
 import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto'
 import { IAssigneeCombined, IAssigneeSuggestions, ITemplate } from '@/types/interfaces'
@@ -59,7 +59,7 @@ export const ClientSideStateUpdate = ({
   task?: TaskResponse
   clearExpandedComments?: boolean
   accesibleTaskIds?: string[]
-  accessibleTasks?: AccessibleTasksResponse[]
+  accessibleTasks?: TaskResponse[]
 }) => {
   const { tasks: tasksInStore, viewSettingsTemp } = useSelector(selectTaskBoard)
   useEffect(() => {
diff --git a/src/hoc/RealTime.tsx b/src/hoc/RealTime.tsx
index 3d2ec1efd..05686b90f 100644
--- a/src/hoc/RealTime.tsx
+++ b/src/hoc/RealTime.tsx
@@ -13,7 +13,7 @@ import store from '@/redux/store'
 import { InternalUsersSchema, Token } from '@/types/common'
 import { TaskResponse } from '@/types/dto/tasks.dto'
 import { extractImgSrcs, replaceImgSrcs } from '@/utils/signedUrlReplacer'
-import { AssigneeType, Task } from '@prisma/client'
+import { AssigneeType } from '@prisma/client'
 import { RealtimePostgresChangesPayload } from '@supabase/supabase-js'
 import { usePathname, useRouter } from 'next/navigation'
 import { ReactNode, useEffect } from 'react'
@@ -47,7 +47,7 @@ export const RealTime = ({
       ? AssigneeType.client
       : undefined
 
-  if (!userId || !userRole) {
+  if (!tokenPayload || !userId || !userRole) {
     console.error(`Failed to authenticate a realtime connection for id ${userId} with role ${userRole}`)
   }
   const user = assignee.find((el) => el.id === userId)
@@ -142,12 +142,18 @@ export const RealTime = ({
             return
           }
           const newTaskArr = tasks.filter((el) => el.id !== updatedTask.id)
+          // Check if any disjoint children were created
+          const newlyDisjointChildren = accessibleTasks.filter((task) => task.parentId === updatedTask.id)
+          newlyDisjointChildren.length && newTaskArr.push(...newlyDisjointChildren)
+
           store.dispatch(setTasks(newTaskArr))
           store.dispatch(setAccesibleTaskIds(accesibleTaskIds.filter((id) => id !== updatedTask.id)))
           store.dispatch(setAccessibleTasks(accessibleTasks.filter((task) => task.id !== updatedTask.id)))
+
           if (updatedTask.id === activeTask?.id) {
-            redirectToBoard(updatedTask)
+            return redirectToBoard(updatedTask)
           }
+
           return
         }
       }
@@ -241,6 +247,11 @@ export const RealTime = ({
           if (activeTask && activeTask.id === updatedTask.id) {
             store.dispatch(setActiveTask(updatedTask))
           }
+
+          // If there are disjoint child tasks floating around in the task board
+          if (tasks.some((task) => task.parentId === updatedTask.id)) {
+            store.dispatch(setTasks(tasks.filter((task) => task.parentId !== updatedTask.id)))
+          }
         }
       }
     }
diff --git a/src/hooks/useFilter.tsx b/src/hooks/useFilter.tsx
index 684cc4b9d..6309fcd9c 100644
--- a/src/hooks/useFilter.tsx
+++ b/src/hooks/useFilter.tsx
@@ -1,6 +1,6 @@
 import { selectTaskBoard, setFilteredTasks } from '@/redux/features/taskBoardSlice'
 import store from '@/redux/store'
-import { AccessibleTasksResponse, TaskResponse } from '@/types/dto/tasks.dto'
+import { TaskResponse } from '@/types/dto/tasks.dto'
 import { FilterOptions, FilterOptionsKeywords, IFilterOptions } from '@/types/interfaces'
 import { useEffect } from 'react'
 import { useSelector } from 'react-redux'
@@ -29,7 +29,7 @@ function filterByAssignee(filteredTasks: TaskResponse[], filterValue: string | n
 function filterByKeyword(
   filteredTasks: TaskResponse[],
   filterValue: string,
-  accessibleTasks?: AccessibleTasksResponse[],
+  accessibleTasks?: TaskResponse[],
 ): TaskResponse[] {
   const keyword = (filterValue as string).toLowerCase()
   const matchKeyword = (task: KeywordMatchable) =>
@@ -42,8 +42,6 @@ function filterByKeyword(
   const keywordMatchingParentIds = accessibleTasks?.filter(matchKeyword).map((task) => task.parentId) || []
   filteredTasks = filteredTasks.filter((task) => {
     // Either match parent with keyword, or match child task with keyword and link it to its parentId
-    console.log('xxxtask', matchKeyword(task))
-    console.log('xxxkwmatch', accessibleTasks?.filter(matchKeyword))
     return matchKeyword(task) || keywordMatchingParentIds.includes(task.id)
   })
 
diff --git a/src/lib/realtime.ts b/src/lib/realtime.ts
index f75d60215..366a6045a 100644
--- a/src/lib/realtime.ts
+++ b/src/lib/realtime.ts
@@ -109,6 +109,11 @@ export class RealtimeHandler {
         store.dispatch(setTasks(filterOutNewTask(tasks)))
       }
       store.dispatch(setAccessibleTasks(filterOutNewTask(accessibleTasks)))
+      // If there are disjoint child tasks floating around in the task board - support multiple levels of nesting for the future
+      if (tasks.some((task) => task.parentId === newTask.id)) {
+        store.dispatch(setTasks(tasks.filter((task) => task.parentId !== newTask.id)))
+      }
+
       return this.handleDelete(newTask)
     }
 
@@ -134,6 +139,7 @@ export class RealtimeHandler {
         ),
       )
     }
+
     if (activeTask && activeTask.id === newTask.id) {
       store.dispatch(setActiveTask(newTask))
     } //updating active task if a user is currently in details page of the task being udpated
@@ -141,9 +147,8 @@ export class RealtimeHandler {
     store.dispatch(
       setAccessibleTasks(
         accessibleTasks.map((task) => {
-          const { id, title, assigneeId, assigneeType, parentId, label } = newTask
           return task.id === newTask.id
-            ? { id, assigneeId, assigneeType, parentId, label, title, body: newTask.body || task.body }
+            ? { ...newTask, body: newTask.body || task.body, createdAt: new Date(newTask.createdAt + 'Z') }
             : task
         }),
       ),
diff --git a/src/redux/features/taskBoardSlice.tsx b/src/redux/features/taskBoardSlice.tsx
index 0fc2c512a..f31ff5783 100644
--- a/src/redux/features/taskBoardSlice.tsx
+++ b/src/redux/features/taskBoardSlice.tsx
@@ -1,6 +1,6 @@
 import { RootState } from '@/redux/store'
 import { PreviewMode } from '@/types/common'
-import { AccessibleTasksResponse, TaskResponse } from '@/types/dto/tasks.dto'
+import { TaskResponse } from '@/types/dto/tasks.dto'
 import { CreateViewSettingsDTO, FilterOptionsType } from '@/types/dto/viewSettings.dto'
 import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto'
 import { FilterByOptions, FilterOptions, IAssigneeCombined, IFilterOptions } from '@/types/interfaces'
@@ -23,7 +23,7 @@ interface IInitialState {
   activeTask: TaskResponse | undefined
   previewMode: PreviewMode
   accesibleTaskIds: string[]
-  accessibleTasks: AccessibleTasksResponse[]
+  accessibleTasks: TaskResponse[]
   confirmAssignModalId: string | undefined
   assigneeCache: Record<string, IAssigneeCombined>
 }
@@ -146,7 +146,7 @@ const taskBoardSlice = createSlice({
     setAccesibleTaskIds: (state, action: { payload: string[] }) => {
       state.accesibleTaskIds = action.payload
     },
-    setAccessibleTasks: (state, action: { payload: AccessibleTasksResponse[] }) => {
+    setAccessibleTasks: (state, action: { payload: TaskResponse[] }) => {
       state.accessibleTasks = action.payload
     },
     setConfirmAssigneeModalId: (state, action: { payload: string | undefined }) => {
diff --git a/src/types/dto/tasks.dto.ts b/src/types/dto/tasks.dto.ts
index 6cad3fe30..97a90954a 100644
--- a/src/types/dto/tasks.dto.ts
+++ b/src/types/dto/tasks.dto.ts
@@ -59,11 +59,6 @@ export const SubTaskStatusSchema = z.object({
 
 export type SubTaskStatusResponse = z.infer<typeof SubTaskStatusSchema>
 
-export type AccessibleTasksResponse = Pick<
-  TaskResponse,
-  'id' | 'assigneeId' | 'assigneeType' | 'title' | 'body' | 'parentId' | 'label'
->
-
 export type AncestorTaskResponse = Pick<Task, 'id' | 'title' | 'label'> & {
   assigneeId: string
   assigneeType: NonNullable<AssigneeType>


Reviews:
user: login: arpandhakal
body: Found an issue on the clients side : 
https://www.loom.com/share/9da3fc1a2e1a415ebc2198a3e1f48cd7
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T09:41:03Z
user: login: arpandhakal
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-18T09:41:25Z
user: login: rrojan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T09:41:56Z
user: login: arpandhakal
body: LGTM
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-18T09:48:46Z

Review comments:
diff_hunk: @@ -66,6 +66,7 @@ export const RealTime = ({
   }
 
   const handleTaskRealTimeUpdates = (payload: RealtimePostgresChangesPayload<RealTimeTaskResponse>) => {
+    console.log('xxx', payload)
path: src/hoc/RealTime.tsx
user: login: arpandhakal
body: remove this log
created_at: 2025-04-18T09:39:13Z
author_association: COLLABORATOR
diff_hunk: @@ -142,12 +143,18 @@ export const RealTime = ({
             return
           }
           const newTaskArr = tasks.filter((el) => el.id !== updatedTask.id)
+          // Check if any disjoint children were created
+          const newlyDisjointChildren = accessibleTasks.filter((task) => task.parentId === updatedTask.id)
+          newlyDisjointChildren.length && newTaskArr.push(...newlyDisjointChildren)
+
           store.dispatch(setTasks(newTaskArr))
           store.dispatch(setAccesibleTaskIds(accesibleTaskIds.filter((id) => id !== updatedTask.id)))
           store.dispatch(setAccessibleTasks(accessibleTasks.filter((task) => task.id !== updatedTask.id)))
+
           if (updatedTask.id === activeTask?.id) {
-            redirectToBoard(updatedTask)
+            return redirectToBoard(updatedTask)
path: src/hoc/RealTime.tsx
user: login: arpandhakal
body: is it safe to return early here. Do we not need rest of the state manipulation to complete?
created_at: 2025-04-18T09:41:05Z
author_association: COLLABORATOR
diff_hunk: @@ -142,12 +143,18 @@ export const RealTime = ({
             return
           }
           const newTaskArr = tasks.filter((el) => el.id !== updatedTask.id)
+          // Check if any disjoint children were created
+          const newlyDisjointChildren = accessibleTasks.filter((task) => task.parentId === updatedTask.id)
+          newlyDisjointChildren.length && newTaskArr.push(...newlyDisjointChildren)
+
           store.dispatch(setTasks(newTaskArr))
           store.dispatch(setAccesibleTaskIds(accesibleTaskIds.filter((id) => id !== updatedTask.id)))
           store.dispatch(setAccessibleTasks(accessibleTasks.filter((task) => task.id !== updatedTask.id)))
+
           if (updatedTask.id === activeTask?.id) {
-            redirectToBoard(updatedTask)
+            return redirectToBoard(updatedTask)
path: src/hoc/RealTime.tsx
user: login: rrojan
body: We are redirecting here so this marks the end of the chain
created_at: 2025-04-18T09:41:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-04-16T13:48:54Z
body: <p><a href="https://linear.app/copilotplatforms/issue/OUT-1625/realtime-issue-when-parenttask-is-updated-on-disjoint-tasks">OUT-1625 Realtime issue when parentTask is updated on disjoint tasks.</a></p>
author_association: NONE
user: login: vercel[bot]
created_at: 2025-04-16T13:48:56Z
body: [vc]: #DdppCyR/FZZNHj9RudwLbAywoYyhLgxS2bQRSn96CjE=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ0YXNrcy1hcHAiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vY29waWxvdC1wbGF0Zm9ybXMvdGFza3MtYXBwLzdrZ1VCRG5FaTlEUGdqYW9HRTlMSHdUaUR3REwiLCJwcmV2aWV3VXJsIjoidGFza3MtYXBwLWdpdC1vdXQtMTYyNS1jb3BpbG90LXBsYXRmb3Jtcy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6InRhc2tzLWFwcC1naXQtb3V0LTE2MjUtY29waWxvdC1wbGF0Zm9ybXMudmVyY2VsLmFwcCJ9LCJyb290RGlyZWN0b3J5IjpudWxsfV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **tasks-app** | ✅ Ready ([Inspect](https://vercel.com/copilot-platforms/tasks-app/7kgUBDnEi9DPgjaoGE9LHwTiDwDL)) | [Visit Preview](https://tasks-app-git-out-1625-copilot-platforms.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/tasks-app-git-out-1625-copilot-platforms.vercel.app?via=pr-comment-feedback-link) | Apr 18, 2025 10:00am |


author_association: CONTRIBUTOR
user: login: rrojan
created_at: 2025-04-18T06:48:13Z
body: Fixed the issue @arpandhakal 


https://github.com/user-attachments/assets/2db2aab8-e307-4f8d-b92d-c66c2aa1662d


author_association: CONTRIBUTOR