From c7c8684e7f6834a9a9d60035738c0bae4a146053 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 16 Dec 2025 18:11:57 +0545 Subject: [PATCH 01/10] fix(OUT-2804): crm subtemplate fixes --- .../manage-templates/[template_id]/page.tsx | 9 +++- src/components/layouts/HeaderBreadcrumbs.tsx | 42 ++++++++++++------- src/hoc/RealTime.tsx | 9 +--- src/hoc/RealtimeTemplates.tsx | 7 +++- .../grant-all-privileges.sql | 2 + src/utils/isRealtimePayloadEqual.ts | 13 ++++++ 6 files changed, 57 insertions(+), 25 deletions(-) create mode 100644 src/utils/isRealtimePayloadEqual.ts diff --git a/src/app/manage-templates/[template_id]/page.tsx b/src/app/manage-templates/[template_id]/page.tsx index 9bbc1f7be..1623634c5 100644 --- a/src/app/manage-templates/[template_id]/page.tsx +++ b/src/app/manage-templates/[template_id]/page.tsx @@ -9,13 +9,14 @@ import { Box } from '@mui/material' import TemplateDetails from '@/app/manage-templates/ui/TemplateDetails' import { deleteTemplate, editTemplate } from '@/app/manage-templates/actions' import { UpdateTemplateRequest } from '@/types/dto/templates.dto' -import { StyledTiptapDescriptionWrapper, TaskDetailsContainer } from '@/app/detail/ui/styledComponent' +import { StyledBox, StyledTiptapDescriptionWrapper, TaskDetailsContainer } from '@/app/detail/ui/styledComponent' import { TemplateSidebar } from '@/app/manage-templates/ui/TemplateSidebar' import { Subtemplates } from '@/app/manage-templates/ui/Subtemplates' import { HeaderBreadcrumbs } from '@/components/layouts/HeaderBreadcrumbs' import { ManageTemplateDetailsAppBridge } from '@/app/manage-templates/ui/ManageTemplatesDetailsAppBridge' import { DeletedRedirectPage } from '@/components/layouts/DeletedRedirectPage' import { OneTemplateDataFetcher } from '@/app/_fetchers/OneTemplateDataFetcher' +import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin' async function getTemplate(id: string, token: string): Promise { const res = await fetch(`${apiUrl}/api/tasks/templates/${id}?token=${token}`, { @@ -68,7 +69,11 @@ export default async function TaskDetailPage({ - + + + + + @@ -60,19 +59,34 @@ export const HeaderBreadcrumbs = ({ variant="breadcrumb" /> - {items.map((item) => ( - - - - {item.label} - - - ))} + {items.map((item, index) => { + const isLast = index === items.length - 1 + + return ( + + {isLast ? ( + <> + + + {item.label} + + + ) : ( + + + + {item.label} + + } + variant="breadcrumb" + /> + + )} + + ) + })} ) } diff --git a/src/hoc/RealTime.tsx b/src/hoc/RealTime.tsx index 240101b23..cce5255cd 100644 --- a/src/hoc/RealTime.tsx +++ b/src/hoc/RealTime.tsx @@ -6,9 +6,9 @@ import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { selectTaskDetails } from '@/redux/features/taskDetailsSlice' import { Token } from '@/types/common' import { TaskResponse } from '@/types/dto/tasks.dto' +import { isPayloadEqual } from '@/utils/isRealtimePayloadEqual' import { AssigneeType } from '@prisma/client' import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' -import deepEqual from 'deep-equal' import { usePathname, useRouter } from 'next/navigation' import { ReactNode, useEffect } from 'react' import { useSelector } from 'react-redux' @@ -60,13 +60,6 @@ export const RealTime = ({ } } - function isPayloadEqual(payload: RealtimePostgresChangesPayload): boolean { - const newPayload = payload.new - const oldPayload = payload.old - if (!newPayload || !oldPayload) return true - return deepEqual(newPayload, oldPayload) - } - const handleRealtimeEvents = (payload: RealtimePostgresChangesPayload) => { if (isPayloadEqual(payload)) { return //no changes for the same payload diff --git a/src/hoc/RealtimeTemplates.tsx b/src/hoc/RealtimeTemplates.tsx index a4757f97e..c8bc9d4af 100644 --- a/src/hoc/RealtimeTemplates.tsx +++ b/src/hoc/RealtimeTemplates.tsx @@ -5,13 +5,14 @@ import { selectCreateTemplate, setActiveTemplate, setTemplates } from '@/redux/f import store from '@/redux/store' import { Token } from '@/types/common' import { ITemplate } from '@/types/interfaces' +import { isPayloadEqual } from '@/utils/isRealtimePayloadEqual' import { extractImgSrcs, replaceImgSrcs } from '@/utils/signedUrlReplacer' import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' import { usePathname, useRouter } from 'next/navigation' import { ReactNode, useEffect } from 'react' import { useSelector } from 'react-redux' -interface RealTimeTemplateResponse extends ITemplate { +export interface RealTimeTemplateResponse extends ITemplate { deletedAt: string } @@ -56,6 +57,10 @@ export const RealTimeTemplates = ({ } const handleTemplatesRealTimeUpdates = (payload: RealtimePostgresChangesPayload) => { + console.log(payload, 'here') + if (isPayloadEqual(payload)) { + return //no changes for the same payload + } if (payload.eventType === 'INSERT') { const newTemplate = payload.new let canUserAccessTask = newTemplate.workspaceId === tokenPayload.workspaceId diff --git a/src/lib/supabase-privilege/grant-all-privileges.sql b/src/lib/supabase-privilege/grant-all-privileges.sql index 94d8431f9..7ef9da8e1 100644 --- a/src/lib/supabase-privilege/grant-all-privileges.sql +++ b/src/lib/supabase-privilege/grant-all-privileges.sql @@ -11,3 +11,5 @@ alter default privileges in schema public grant all on sequences to postgres, an alter table "Tasks" replica identity full; +alter table + "TaskTemplates" replica identity full; diff --git a/src/utils/isRealtimePayloadEqual.ts b/src/utils/isRealtimePayloadEqual.ts new file mode 100644 index 000000000..ef19d8b85 --- /dev/null +++ b/src/utils/isRealtimePayloadEqual.ts @@ -0,0 +1,13 @@ +import { RealTimeTaskResponse } from '@/hoc/RealTime' +import { RealTimeTemplateResponse } from '@/hoc/RealtimeTemplates' +import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' +import deepEqual from 'deep-equal' + +export function isPayloadEqual( + payload: RealtimePostgresChangesPayload, +): boolean { + const newPayload = payload.new + const oldPayload = payload.old + if (!newPayload || !oldPayload) return true + return deepEqual(newPayload, oldPayload) +} From 81f243709ede82ba73eebef7274f3fedbd4f34c5 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Thu, 18 Dec 2025 13:14:25 +0545 Subject: [PATCH 02/10] fix(OUT-2804): realtime fixes on template --- src/app/(home)/page.tsx | 21 +- src/app/detail/[task_id]/[user_type]/page.tsx | 241 +++++++++--------- src/hoc/RealTime.tsx | 4 +- src/hoc/RealtimeTemplates.tsx | 24 +- src/utils/isRealtimePayloadEqual.ts | 20 +- 5 files changed, 173 insertions(+), 137 deletions(-) diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx index 9cc4584b6..55c8a8f07 100644 --- a/src/app/(home)/page.tsx +++ b/src/app/(home)/page.tsx @@ -24,6 +24,7 @@ import { UserRole } from '@api/core/types/user' import { Suspense } from 'react' import { z } from 'zod' import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler' +import { RealTimeTemplates } from '@/hoc/RealtimeTemplates' export async function getAllWorkflowStates(token: string): Promise { const res = await fetch(`${apiUrl}/api/workflow-states?token=${token}`, { @@ -133,15 +134,17 @@ export default async function Main({ - - - - { - 'use server' - await createMultipleAttachments(token, attachments) - }} - /> + + + + + { + 'use server' + await createMultipleAttachments(token, attachments) + }} + /> + diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index 4f115a41d..7b45ccc68 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -32,6 +32,7 @@ import { apiUrl } from '@/config' import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin' import CustomScrollBar from '@/hoc/CustomScrollBar' import { RealTime } from '@/hoc/RealTime' +import { RealTimeTemplates } from '@/hoc/RealtimeTemplates' import { WorkspaceResponse } from '@/types/common' import { AncestorTaskResponse, SubTaskStatusResponse, TaskResponse } from '@/types/dto/tasks.dto' import { UserType } from '@/types/interfaces' @@ -135,131 +136,133 @@ export default async function TaskDetailPage({ > {token && } - - - - {isPreviewMode ? ( - - - - - - + + + + + {isPreviewMode ? ( + + + + - + + + + - - - - ) : ( - <> - - - - )} - - - - - - { - 'use server' - await updateTaskDetail({ token, taskId: task_id, payload: { body: detail } }) - }} - updateTaskTitle={async (title) => { - 'use server' - title.trim() != '' && (await updateTaskDetail({ token, taskId: task_id, payload: { title } })) - }} - deleteTask={async () => { - 'use server' - await deleteTask(token, task_id) - }} - postAttachment={async (postAttachmentPayload) => { - 'use server' - await postAttachment(token, postAttachmentPayload) - }} - deleteAttachment={async (id: string) => { - 'use server' - await deleteAttachment(token, id) - }} - userType={params.user_type} - token={token} - /> - - {subTaskStatus.canCreateSubtask && ( - + + ) : ( + <> + - )} + + + )} + + + + + + { + 'use server' + await updateTaskDetail({ token, taskId: task_id, payload: { body: detail } }) + }} + updateTaskTitle={async (title) => { + 'use server' + title.trim() != '' && (await updateTaskDetail({ token, taskId: task_id, payload: { title } })) + }} + deleteTask={async () => { + 'use server' + await deleteTask(token, task_id) + }} + postAttachment={async (postAttachmentPayload) => { + 'use server' + await postAttachment(token, postAttachmentPayload) + }} + deleteAttachment={async (id: string) => { + 'use server' + await deleteAttachment(token, id) + }} + userType={params.user_type} + token={token} + /> + + {subTaskStatus.canCreateSubtask && ( + + )} - - - - - - - - - { - 'use server' - params.user_type === UserType.CLIENT_USER && !getPreviewMode(tokenPayload) - ? await clientUpdateTask(token, task_id, workflowState.id) - : await updateWorkflowStateIdOfTask(token, task_id, workflowState?.id) - }} - updateAssignee={async ({ internalUserId, clientId, companyId, viewers }: UserIdsWithViewersType) => { - 'use server' - await updateAssignee(token, task_id, internalUserId, clientId, companyId, viewers) - }} - updateTask={async (payload) => { - 'use server' - await updateTaskDetail({ token, taskId: task_id, payload }) - }} - disabled={params.user_type === UserType.CLIENT_USER} - workflowDisabled={isViewer} - /> - - + + + + + + + + + { + 'use server' + params.user_type === UserType.CLIENT_USER && !getPreviewMode(tokenPayload) + ? await clientUpdateTask(token, task_id, workflowState.id) + : await updateWorkflowStateIdOfTask(token, task_id, workflowState?.id) + }} + updateAssignee={async ({ internalUserId, clientId, companyId, viewers }: UserIdsWithViewersType) => { + 'use server' + await updateAssignee(token, task_id, internalUserId, clientId, companyId, viewers) + }} + updateTask={async (payload) => { + 'use server' + await updateTaskDetail({ token, taskId: task_id, payload }) + }} + disabled={params.user_type === UserType.CLIENT_USER} + workflowDisabled={isViewer} + /> + + + ) diff --git a/src/hoc/RealTime.tsx b/src/hoc/RealTime.tsx index cce5255cd..1d935468e 100644 --- a/src/hoc/RealTime.tsx +++ b/src/hoc/RealTime.tsx @@ -6,7 +6,7 @@ import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { selectTaskDetails } from '@/redux/features/taskDetailsSlice' import { Token } from '@/types/common' import { TaskResponse } from '@/types/dto/tasks.dto' -import { isPayloadEqual } from '@/utils/isRealtimePayloadEqual' +import { isTaskPayloadEqual } from '@/utils/isRealtimePayloadEqual' import { AssigneeType } from '@prisma/client' import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' import { usePathname, useRouter } from 'next/navigation' @@ -61,7 +61,7 @@ export const RealTime = ({ } const handleRealtimeEvents = (payload: RealtimePostgresChangesPayload) => { - if (isPayloadEqual(payload)) { + if (isTaskPayloadEqual(payload)) { return //no changes for the same payload } const user = assignee.find((el) => el.id === userId) diff --git a/src/hoc/RealtimeTemplates.tsx b/src/hoc/RealtimeTemplates.tsx index c8bc9d4af..50e68e422 100644 --- a/src/hoc/RealtimeTemplates.tsx +++ b/src/hoc/RealtimeTemplates.tsx @@ -5,7 +5,7 @@ import { selectCreateTemplate, setActiveTemplate, setTemplates } from '@/redux/f import store from '@/redux/store' import { Token } from '@/types/common' import { ITemplate } from '@/types/interfaces' -import { isPayloadEqual } from '@/utils/isRealtimePayloadEqual' +import { isTemplatePayloadEqual } from '@/utils/isRealtimePayloadEqual' import { extractImgSrcs, replaceImgSrcs } from '@/utils/signedUrlReplacer' import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' import { usePathname, useRouter } from 'next/navigation' @@ -32,7 +32,7 @@ export const RealTimeTemplates = ({ const pathname = usePathname() const router = useRouter() - const applySubtemplateToActiveTemplate = (newTemplate: RealTimeTemplateResponse) => { + const applySubtemplateToParentTemplate = (newTemplate: RealTimeTemplateResponse) => { if (!newTemplate?.parentId) return if (activeTemplate?.id === newTemplate.parentId) { @@ -43,6 +43,19 @@ export const RealTimeTemplates = ({ }), ) } + + store.dispatch( + setTemplates( + templates.map((template) => + template.id === newTemplate.parentId + ? { + ...template, + subTaskTemplates: [...(template.subTaskTemplates || []), newTemplate], + } + : template, + ), + ), + ) //also append the subTaskTemplates to parent template on the templates store. } const redirectBack = (updatedTemplate: RealTimeTemplateResponse) => { @@ -57,8 +70,7 @@ export const RealTimeTemplates = ({ } const handleTemplatesRealTimeUpdates = (payload: RealtimePostgresChangesPayload) => { - console.log(payload, 'here') - if (isPayloadEqual(payload)) { + if (isTemplatePayloadEqual(payload)) { return //no changes for the same payload } if (payload.eventType === 'INSERT') { @@ -66,7 +78,7 @@ export const RealTimeTemplates = ({ let canUserAccessTask = newTemplate.workspaceId === tokenPayload.workspaceId if (!canUserAccessTask) return if (newTemplate?.parentId) { - applySubtemplateToActiveTemplate(newTemplate) + applySubtemplateToParentTemplate(newTemplate) return } templates @@ -110,7 +122,7 @@ export const RealTimeTemplates = ({ ) } if (updatedTemplate?.parentId) { - applySubtemplateToActiveTemplate(updatedTemplate) + applySubtemplateToParentTemplate(updatedTemplate) return } const newTemplateArr = [ diff --git a/src/utils/isRealtimePayloadEqual.ts b/src/utils/isRealtimePayloadEqual.ts index ef19d8b85..6ef32e0c5 100644 --- a/src/utils/isRealtimePayloadEqual.ts +++ b/src/utils/isRealtimePayloadEqual.ts @@ -3,7 +3,7 @@ import { RealTimeTemplateResponse } from '@/hoc/RealtimeTemplates' import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' import deepEqual from 'deep-equal' -export function isPayloadEqual( +export function isTaskPayloadEqual( payload: RealtimePostgresChangesPayload, ): boolean { const newPayload = payload.new @@ -11,3 +11,21 @@ export function isPayloadEqual( if (!newPayload || !oldPayload) return true return deepEqual(newPayload, oldPayload) } + +export function isTemplatePayloadEqual(payload: RealtimePostgresChangesPayload): boolean { + const { new: n, old: o } = payload + + const hasRequiredFields = (obj: {} | RealTimeTemplateResponse): obj is RealTimeTemplateResponse => + typeof obj === 'object' && + obj !== null && + 'title' in obj && + 'body' in obj && + 'workflowStateId' in obj && + 'deletedAt' in obj + + if (!hasRequiredFields(n) || !hasRequiredFields(o)) { + return false + } + + return n.title === o.title && n.body === o.body && n.workflowStateId === o.workflowStateId && n.deletedAt === o.deletedAt +} From 0cfeb7623adf01f4613feb855968ff1cf874b00e Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 16 Dec 2025 18:46:19 +0545 Subject: [PATCH 03/10] fix(OUT-2807): notification center view template issues - Manage templates button on template options hidden when user is from notification center. - added template fetcher on tasks details page so that user can access templates dropdown on notification-center-view --- src/app/detail/[task_id]/[user_type]/page.tsx | 5 +++++ src/app/detail/ui/NewTaskCard.tsx | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index 7b45ccc68..c5c2e6155 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -4,6 +4,7 @@ import { AssigneeCacheGetter } from '@/app/_cache/AssigneeCacheGetter' import { AssigneeFetcher } from '@/app/_fetchers/AssigneeFetcher' import { fetchWithErrorHandler } from '@/app/_fetchers/fetchWithErrorHandler' import { OneTaskDataFetcher } from '@/app/_fetchers/OneTaskDataFetcher' +import { TemplatesFetcher } from '@/app/_fetchers/TemplatesFetcher' import { WorkflowStateFetcher } from '@/app/_fetchers/WorkflowStateFetcher' import { UserRole } from '@/app/api/core/types/user' import { @@ -42,6 +43,7 @@ import EscapeHandler from '@/utils/escapeHandler' import { getPreviewMode } from '@/utils/previewMode' import { checkIfTaskViewer } from '@/utils/taskViewer' import { Box, Stack } from '@mui/material' +import { Suspense } from 'react' import { z } from 'zod' async function getOneTask(token: string, taskId: string): Promise { @@ -135,6 +137,9 @@ export default async function TaskDetailPage({ workspace={workspace} > {token && } + + + diff --git a/src/app/detail/ui/NewTaskCard.tsx b/src/app/detail/ui/NewTaskCard.tsx index 713dfa97f..a2cf03be5 100644 --- a/src/app/detail/ui/NewTaskCard.tsx +++ b/src/app/detail/ui/NewTaskCard.tsx @@ -15,6 +15,7 @@ import { useHandleSelectorComponent } from '@/hooks/useHandleSelectorComponent' import { PersonIconSmall, TempalteIconMd } from '@/icons' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' +import { selectTaskDetails } from '@/redux/features/taskDetailsSlice' import { selectCreateTemplate } from '@/redux/features/templateSlice' import { DateString } from '@/types/date' import { CreateTaskRequest, Viewers } from '@/types/dto/tasks.dto' @@ -53,6 +54,7 @@ export const NewTaskCard = ({ }) => { const { workflowStates, assignee, token, activeTask, previewMode, previewClientCompany } = useSelector(selectTaskBoard) const { templates } = useSelector(selectCreateTemplate) + const { fromNotificationCenter } = useSelector(selectTaskDetails) const [isEditorReadonly, setIsEditorReadonly] = useState(false) @@ -289,7 +291,7 @@ export const NewTaskCard = ({ placeholder="Search..." value={templateValue} selectorType={SelectorType.TEMPLATE_SELECTOR} - endOption={} + endOption={!fromNotificationCenter && } endOptionHref={`/manage-templates?token=${token}`} listAutoHeightMax="147px" variant="normal" From 355fb31794f5793d19a851f7c3773276999abfbf Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 17 Dec 2025 14:33:57 +0545 Subject: [PATCH 04/10] fix(OUT-2810): fixed templates card rearraging on hover and some design fixes - Sorted templates on templates board by createdAt. The rearrangement was caused because of updating redux store on hover. - Removed extra line at the top of tempalte detail page. --- src/app/detail/ui/Subtasks.tsx | 2 +- src/app/manage-templates/[template_id]/page.tsx | 11 +++++++++-- src/app/manage-templates/ui/Subtemplates.tsx | 2 +- src/app/manage-templates/ui/TemplateBoard.tsx | 3 ++- src/app/ui/TaskBoard.tsx | 2 +- src/hoc/RealtimeTemplates.tsx | 2 -- src/utils/{sortTask.ts => sortByDescending.ts} | 12 ++++++++++++ 7 files changed, 26 insertions(+), 8 deletions(-) rename src/utils/{sortTask.ts => sortByDescending.ts} (67%) diff --git a/src/app/detail/ui/Subtasks.tsx b/src/app/detail/ui/Subtasks.tsx index da135df7f..1ae25a735 100644 --- a/src/app/detail/ui/Subtasks.tsx +++ b/src/app/detail/ui/Subtasks.tsx @@ -16,7 +16,7 @@ import { fetcher } from '@/utils/fetcher' import { generateRandomString } from '@/utils/generateRandomString' import { checkOptimisticStableId } from '@/utils/optimisticCommentUtils' import { getTempTask } from '@/utils/optimisticTaskUtils' -import { sortTaskByDescendingOrder } from '@/utils/sortTask' +import { sortTaskByDescendingOrder } from '@/utils/sortByDescending' import { Box, Stack, Typography } from '@mui/material' import { useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' diff --git a/src/app/manage-templates/[template_id]/page.tsx b/src/app/manage-templates/[template_id]/page.tsx index 1623634c5..756500abd 100644 --- a/src/app/manage-templates/[template_id]/page.tsx +++ b/src/app/manage-templates/[template_id]/page.tsx @@ -17,6 +17,7 @@ import { ManageTemplateDetailsAppBridge } from '@/app/manage-templates/ui/Manage import { DeletedRedirectPage } from '@/components/layouts/DeletedRedirectPage' import { OneTemplateDataFetcher } from '@/app/_fetchers/OneTemplateDataFetcher' import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin' +import { getPreviewMode } from '@/utils/previewMode' async function getTemplate(id: string, token: string): Promise { const res = await fetch(`${apiUrl}/api/tasks/templates/${id}?token=${token}`, { @@ -62,6 +63,8 @@ export default async function TaskDetailPage({ })), ] + const isPreviewMode = !!getPreviewMode(tokenPayload) + return ( {token && } @@ -70,9 +73,13 @@ export default async function TaskDetailPage({ - + {isPreviewMode ? ( + + + + ) : ( - + )} - {templates.map((template) => { + {sortTemplatesByDescendingOrder(templates).map((template) => { return ( template.id == updatedTemplate.id) if (payload.new.workspaceId === tokenPayload.workspaceId) { if (updatedTemplate.deletedAt) { diff --git a/src/utils/sortTask.ts b/src/utils/sortByDescending.ts similarity index 67% rename from src/utils/sortTask.ts rename to src/utils/sortByDescending.ts index 08e4e4fb8..3e9100360 100644 --- a/src/utils/sortTask.ts +++ b/src/utils/sortByDescending.ts @@ -23,3 +23,15 @@ export const sortTaskByDescendingOrder = (tasks: T[]): T[] = return createdAtDiff !== 0 ? createdAtDiff : a.id.localeCompare(b.id) }) } + +interface TemplateSortable { + createdAt: Date + id: string +} + +export const sortTemplatesByDescendingOrder = (templates: readonly T[]): T[] => { + return [...templates].sort((a, b) => { + const createdAtDiff = getTimestamp(b.createdAt) - getTimestamp(a.createdAt) + return createdAtDiff !== 0 ? createdAtDiff : a.id.localeCompare(b.id) + }) +} From 5a7512481794f42553eb4746cf9de6ef69d98b29 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 17 Dec 2025 15:22:11 +0545 Subject: [PATCH 05/10] fix(OUT-2810): appended z on realtime templates --- src/hoc/RealtimeTemplates.tsx | 16 ++++++++++++++-- src/types/interfaces.ts | 4 ++-- src/utils/optimisticTaskUtils.ts | 4 ++-- src/utils/sortByDescending.ts | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/hoc/RealtimeTemplates.tsx b/src/hoc/RealtimeTemplates.tsx index d464b2796..55db9fb32 100644 --- a/src/hoc/RealtimeTemplates.tsx +++ b/src/hoc/RealtimeTemplates.tsx @@ -58,6 +58,18 @@ export const RealTimeTemplates = ({ ) //also append the subTaskTemplates to parent template on the templates store. } + function getFormattedTemplate(template: unknown): RealTimeTemplateResponse { + const newTemplate = template as RealTimeTemplateResponse + // NOTE: we append a Z here to make JS understand this raw timestamp (in format YYYY-MM-DD:HH:MM:SS.MS) is in UTC timezone + // New payloads listened on the 'INSERT' action in realtime doesn't contain this tz info so the order can mess up, + // causing tasks to bounce around on hover + return { + ...newTemplate, + createdAt: newTemplate.createdAt && new Date(newTemplate.createdAt + 'Z').toISOString(), + updatedAt: newTemplate.updatedAt && new Date(newTemplate.updatedAt + 'Z').toISOString(), + } + } + const redirectBack = (updatedTemplate: RealTimeTemplateResponse) => { //disable board navigation if not in template details page if (!pathname.includes(`manage-templates/${updatedTemplate.id}`)) return @@ -74,7 +86,7 @@ export const RealTimeTemplates = ({ return //no changes for the same payload } if (payload.eventType === 'INSERT') { - const newTemplate = payload.new + const newTemplate = getFormattedTemplate(payload.new) let canUserAccessTask = newTemplate.workspaceId === tokenPayload.workspaceId if (!canUserAccessTask) return if (newTemplate?.parentId) { @@ -86,7 +98,7 @@ export const RealTimeTemplates = ({ : store.dispatch(setTemplates([{ ...newTemplate }])) } if (payload.eventType === 'UPDATE') { - const updatedTemplate = payload.new + const updatedTemplate = getFormattedTemplate(payload.new) const oldTemplate = templates && templates.find((template) => template.id == updatedTemplate.id) if (payload.new.workspaceId === tokenPayload.workspaceId) { diff --git a/src/types/interfaces.ts b/src/types/interfaces.ts index dce8e767f..c96f072df 100644 --- a/src/types/interfaces.ts +++ b/src/types/interfaces.ts @@ -180,8 +180,8 @@ export interface ITemplate { body: string workflowStateId: string createdBy: string - createdAt: Date - updatedAt: Date + createdAt: string + updatedAt: string parentId: string | null subTaskTemplates: ITemplate[] parent?: ITemplate | null diff --git a/src/utils/optimisticTaskUtils.ts b/src/utils/optimisticTaskUtils.ts index c96f8ef52..cd0e4c0cb 100644 --- a/src/utils/optimisticTaskUtils.ts +++ b/src/utils/optimisticTaskUtils.ts @@ -54,8 +54,8 @@ export const getTempTaskTemplate = ( body: payload.body ?? '', workflowStateId: payload.workflowStateId, createdBy: userId, - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), parentId: parentId, subTaskTemplates: [], } diff --git a/src/utils/sortByDescending.ts b/src/utils/sortByDescending.ts index 3e9100360..41f0f551e 100644 --- a/src/utils/sortByDescending.ts +++ b/src/utils/sortByDescending.ts @@ -25,7 +25,7 @@ export const sortTaskByDescendingOrder = (tasks: T[]): T[] = } interface TemplateSortable { - createdAt: Date + createdAt: string id: string } From 60afd6e6e466f6c2184c8f602ce615d083dd1abe Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Thu, 18 Dec 2025 14:32:15 +0545 Subject: [PATCH 06/10] fix(OUT-2810): made generic functions for task and templates instead of having separate ones, memoized templats list --- src/app/manage-templates/ui/TemplateBoard.tsx | 6 ++- src/hoc/RealtimeTemplates.tsx | 13 +----- src/lib/realtime.ts | 26 +++-------- src/utils/getFormattedRealTimeData.ts | 30 +++++++++++++ src/utils/sortByDescending.ts | 44 +++++++++---------- 5 files changed, 63 insertions(+), 56 deletions(-) create mode 100644 src/utils/getFormattedRealTimeData.ts diff --git a/src/app/manage-templates/ui/TemplateBoard.tsx b/src/app/manage-templates/ui/TemplateBoard.tsx index 97dbb9f5e..5482daa28 100644 --- a/src/app/manage-templates/ui/TemplateBoard.tsx +++ b/src/app/manage-templates/ui/TemplateBoard.tsx @@ -14,6 +14,7 @@ import { useSelector } from 'react-redux' import { NoTemplateLayout } from './NoTemplateLayout' import { TemplateForm } from './TemplateForm' import { sortTemplatesByDescendingOrder } from '@/utils/sortByDescending' +import { useMemo } from 'react' export const TemplateBoard = ({ handleCreateTemplate, @@ -28,6 +29,7 @@ export const TemplateBoard = ({ useSelector(selectCreateTemplate) const { token, previewMode } = useSelector(selectTaskBoard) + const sortedTemplates = useMemo(() => sortTemplatesByDescendingOrder(templates), [templates]) if (templates === undefined) { return null @@ -38,7 +40,7 @@ export const TemplateBoard = ({ <> {showHeader && } - {templates.length ? ( + {sortedTemplates.length ? ( - {sortTemplatesByDescendingOrder(templates).map((template) => { + {sortedTemplates.map((template) => { return ( { //disable board navigation if not in template details page if (!pathname.includes(`manage-templates/${updatedTemplate.id}`)) return diff --git a/src/lib/realtime.ts b/src/lib/realtime.ts index c23db3c26..08d9dc967 100644 --- a/src/lib/realtime.ts +++ b/src/lib/realtime.ts @@ -6,6 +6,7 @@ import store from '@/redux/store' import { InternalUsersSchema, Token } from '@/types/common' import { TaskResponse } from '@/types/dto/tasks.dto' import { IAssigneeCombined } from '@/types/interfaces' +import { getFormattedTask } from '@/utils/getFormattedRealTimeData' import { getPreviewMode } from '@/utils/previewMode' import { extractImgSrcs, replaceImgSrcs } from '@/utils/signedUrlReplacer' import { AssigneeType } from '@prisma/client' @@ -20,27 +21,13 @@ export class RealtimeHandler { private readonly redirectToBoard: (newTask: RealTimeTaskResponse) => void, private readonly tokenPayload: Token, ) { - const newTask = this.getFormattedTask(this.payload.new) + const newTask = getFormattedTask(this.payload.new) if (newTask.workspaceId !== tokenPayload.workspaceId) { console.error('Realtime event ignored for task with different workspaceId') return } } - private getFormattedTask(task: unknown): RealTimeTaskResponse { - const newTask = task as RealTimeTaskResponse - // NOTE: we append a Z here to make JS understand this raw timestamp (in format YYYY-MM-DD:HH:MM:SS.MS) is in UTC timezone - // New payloads listened on the 'INSERT' action in realtime doesn't contain this tz info so the order can mess up, - // causing tasks to bounce around on hover - return { - ...newTask, - createdAt: newTask.createdAt && new Date(newTask.createdAt + 'Z').toISOString(), - updatedAt: newTask.updatedAt && new Date(newTask.updatedAt + 'Z').toISOString(), - lastActivityLogUpdated: newTask.lastActivityLogUpdated && new Date(newTask.lastActivityLogUpdated + 'Z').toISOString(), - lastSubtaskUpdated: newTask.lastSubtaskUpdated && new Date(newTask.lastSubtaskUpdated + 'Z').toISOString(), - } - } - private isViewer(newTask: RealTimeTaskResponse): boolean { return this.tokenPayload.clientId || !!getPreviewMode(this.tokenPayload) ? (newTask.viewers?.some( @@ -188,7 +175,7 @@ export class RealtimeHandler { const currentState = store.getState() const { tasks, accessibleTasks } = selectTaskBoard(currentState) - const newTask = this.getFormattedTask(this.payload.new) + const newTask = getFormattedTask(this.payload.new) // Being a subtask, this surely has a valid non-null parentId newTask.parentId = z.string().parse(newTask.parentId) @@ -215,7 +202,7 @@ export class RealtimeHandler { * Handler for realtime task inserts */ handleRealtimeTaskInsert() { - const newTask = this.getFormattedTask(this.payload.new) + const newTask = getFormattedTask(this.payload.new) const commonStore = store.getState() const { accessibleTasks, showUnarchived, tasks } = commonStore.taskBoard @@ -265,8 +252,9 @@ export class RealtimeHandler { * Handler for realtime task update events */ handleRealtimeTaskUpdate() { - const updatedTask = this.getFormattedTask(this.payload.new) - const prevTask = this.getFormattedTask(this.payload.old) + const updatedTask = getFormattedTask(this.payload.new) + const prevTask = getFormattedTask(this.payload.old) + const commonStore = store.getState() const { activeTask, accessibleTasks, showArchived, showUnarchived, tasks } = commonStore.taskBoard diff --git a/src/utils/getFormattedRealTimeData.ts b/src/utils/getFormattedRealTimeData.ts new file mode 100644 index 000000000..a777f4246 --- /dev/null +++ b/src/utils/getFormattedRealTimeData.ts @@ -0,0 +1,30 @@ +import { RealTimeTaskResponse } from '@/hoc/RealTime' +import { RealTimeTemplateResponse } from '@/hoc/RealtimeTemplates' + +type TimestampKeys = Extract + +function formatTimestamps>(obj: T, keys: TimestampKeys[]): T { + const formatted: Partial = { ...obj } + + keys.forEach((key) => { + const value = obj[key] + if (typeof value === 'string') { + formatted[key] = new Date(value + 'Z').toISOString() as any + } + }) + + return formatted as T +} + +export function getFormattedTask(task: unknown): RealTimeTaskResponse { + return formatTimestamps(task as RealTimeTaskResponse, [ + 'createdAt', + 'updatedAt', + 'lastActivityLogUpdated', + 'lastSubtaskUpdated', + ]) +} + +export function getFormattedTemplate(template: unknown): RealTimeTemplateResponse { + return formatTimestamps(template as RealTimeTemplateResponse, ['createdAt', 'updatedAt']) +} diff --git a/src/utils/sortByDescending.ts b/src/utils/sortByDescending.ts index 41f0f551e..621961286 100644 --- a/src/utils/sortByDescending.ts +++ b/src/utils/sortByDescending.ts @@ -1,37 +1,35 @@ -interface Sortable { - dueDate?: string +interface BaseSortable { createdAt: string id: string } +interface WithDueDate extends BaseSortable { + dueDate?: string +} + const getTimestamp = (date: string | Date) => new Date(date).getTime() -export const sortTaskByDescendingOrder = (tasks: T[]): T[] => { - return tasks.sort((a, b) => { - // Prioritize tasks with due dates over tasks without due dates - if (a.dueDate && !b.dueDate) { - return -1 - } else if (b.dueDate && !a.dueDate) { - return 1 - } else if (a.dueDate && b.dueDate) { - // Sort by duedate in asc order. - if (a.dueDate !== b.dueDate) { - return getTimestamp(a.dueDate) - getTimestamp(b.dueDate) +export const sortByDescendingOrder = ( + items: T[], + priorityKey?: K, +): T[] => { + return [...items].sort((a, b) => { + if (priorityKey) { + const aVal = a[priorityKey] as unknown as string | undefined + const bVal = b[priorityKey] as unknown as string | undefined + + if (aVal && !bVal) return -1 + if (bVal && !aVal) return 1 + if (aVal && bVal && aVal !== bVal) { + return getTimestamp(aVal) - getTimestamp(bVal) } } + const createdAtDiff = getTimestamp(b.createdAt) - getTimestamp(a.createdAt) return createdAtDiff !== 0 ? createdAtDiff : a.id.localeCompare(b.id) }) } -interface TemplateSortable { - createdAt: string - id: string -} +export const sortTaskByDescendingOrder = (tasks: T[]) => sortByDescendingOrder(tasks, 'dueDate') -export const sortTemplatesByDescendingOrder = (templates: readonly T[]): T[] => { - return [...templates].sort((a, b) => { - const createdAtDiff = getTimestamp(b.createdAt) - getTimestamp(a.createdAt) - return createdAtDiff !== 0 ? createdAtDiff : a.id.localeCompare(b.id) - }) -} +export const sortTemplatesByDescendingOrder = (templates: T[]) => sortByDescendingOrder(templates) From aa3fe7e9b9419ffb27ade200132cf314115f5768 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Thu, 18 Dec 2025 14:32:36 +0545 Subject: [PATCH 07/10] fix(OUT-2810): made generic functions for task and templates instead of having separate ones, memoized templats list --- src/app/manage-templates/ui/TemplateBoard.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/app/manage-templates/ui/TemplateBoard.tsx b/src/app/manage-templates/ui/TemplateBoard.tsx index 5482daa28..030bdf5f2 100644 --- a/src/app/manage-templates/ui/TemplateBoard.tsx +++ b/src/app/manage-templates/ui/TemplateBoard.tsx @@ -31,9 +31,6 @@ export const TemplateBoard = ({ const { token, previewMode } = useSelector(selectTaskBoard) const sortedTemplates = useMemo(() => sortTemplatesByDescendingOrder(templates), [templates]) - if (templates === undefined) { - return null - } const showHeader = token && !!previewMode return ( From a60f20a61168c8affb2390bdef46033bb62b96f6 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 17 Dec 2025 17:20:28 +0545 Subject: [PATCH 08/10] fix(OUT-2813): subtasks not being created in proper order from templates - added manual timestamps for subtasks while creating them from a template with respect to parent templates createdAt. - this process maintains that the subtasks are created parallely and in order which gives us both performance and ordering. - found another issue where subtasks creation from templates causes race condition on realtime, causing update events to fire before create events for subtasks, causing duplicate data. Fixed this. --- src/app/api/tasks/tasks.service.ts | 16 ++++++++++------ src/lib/realtime.ts | 4 +++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index 69c606b80..104162bdf 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -163,7 +163,10 @@ export class TasksService extends BaseService { return filteredTasks } - async createTask(data: CreateTaskRequest, opts?: { isPublicApi?: boolean; disableSubtaskTemplates?: boolean }) { + async createTask( + data: CreateTaskRequest, + opts?: { isPublicApi?: boolean; disableSubtaskTemplates?: boolean; manualTimestamp?: Date }, + ) { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Create, Resource.Tasks) console.info('TasksService#createTask | Creating task with data:', data) @@ -235,6 +238,7 @@ export class TasksService extends BaseService { assigneeType, viewers: viewers, ...validatedIds, + ...(opts?.manualTimestamp && { createdAt: opts.manualTimestamp }), ...(await getTaskTimestamps('create', this.user, data, undefined, workflowStateStatus)), }, include: { workflowState: true }, @@ -305,9 +309,10 @@ export class TasksService extends BaseService { if (template.subTaskTemplates.length) { await Promise.all( - template.subTaskTemplates.map(async (sub) => { + template.subTaskTemplates.map(async (sub, index) => { const updatedSubTemplate = await templateService.getAppliedTemplateDescription(sub.id) - await this.createSubtasksFromTemplate(updatedSubTemplate, newTask.id) + const manualTimeStamp = new Date(template.createdAt.getTime() + (template.subTaskTemplates.length - index) * 10) //maintain the order of subtasks in tasks with respect to subtasks in templates + await this.createSubtasksFromTemplate(updatedSubTemplate, newTask.id, manualTimeStamp) }), ) } @@ -1149,7 +1154,7 @@ export class TasksService extends BaseService { return viewers } - private async createSubtasksFromTemplate(data: TaskTemplate, parentId: string) { + private async createSubtasksFromTemplate(data: TaskTemplate, parentId: string, manualTimestamp: Date) { const { workspaceId, title, body, workflowStateId } = data try { const createTaskPayload = CreateTaskRequestSchema.parse({ @@ -1158,10 +1163,9 @@ export class TasksService extends BaseService { workspaceId, workflowStateId, parentId, - templateId: undefined, //just to be safe from circular recursion }) - await this.createTask(createTaskPayload, { disableSubtaskTemplates: true }) + await this.createTask(createTaskPayload, { disableSubtaskTemplates: true, manualTimestamp: manualTimestamp }) } catch (e) { const deleteTask = this.db.task.delete({ where: { id: parentId } }) const deleteActivityLogs = this.db.activityLog.deleteMany({ where: { taskId: parentId } }) diff --git a/src/lib/realtime.ts b/src/lib/realtime.ts index 08d9dc967..a4c0123a5 100644 --- a/src/lib/realtime.ts +++ b/src/lib/realtime.ts @@ -193,7 +193,9 @@ export class RealtimeHandler { return this.handleRealtimeSubtaskInsert(newTask) } if (this.payload.eventType === 'UPDATE') { - return this.handleRealtimeSubtaskUpdate(newTask) + return setTimeout(() => { + this.handleRealtimeSubtaskUpdate(newTask) + }, 0) //avoid race condition causing duplicate data when update is triggered before create. } console.error('Unknown event type for realtime subtask handler') } From d0ea68628da4631f46a70a9f7cdc8a0e60bec546 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Thu, 18 Dec 2025 15:07:01 +0545 Subject: [PATCH 09/10] fix(OUT-2816): new task form description height --- src/app/ui/NewTaskForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index bf2f32449..8e19373ba 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -619,7 +619,7 @@ const NewTaskFormInputs = ({ isEditorReadonly }: NewTaskFormInputsProps) => { deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', null, null)} attachmentLayout={AttachmentLayout} maxUploadLimit={MAX_UPLOAD_LIMIT} - parentContainerStyle={{ gap: '0px', height: '66px' }} + parentContainerStyle={{ gap: '0px', minHeight: '60px' }} /> From d0b86febd6decc853923cb39d9034d112a5e9a4c Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Thu, 18 Dec 2025 18:35:05 +0545 Subject: [PATCH 10/10] fix(OUT-2816): task being created without adding template description --- src/app/ui/NewTaskForm.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index 8e19373ba..f549d92e9 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -410,6 +410,7 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => handleCreate={handleCreateWithAssignee} handleClose={handleClose} updateWorkflowStatusValue={updateStatusValue} + creationDisabled={isEditorReadonly} /> ) @@ -630,7 +631,8 @@ const NewTaskFormInputs = ({ isEditorReadonly }: NewTaskFormInputsProps) => { const NewTaskFooter = ({ handleCreate, handleClose, -}: NewTaskFormProps & { updateWorkflowStatusValue: (value: unknown) => void }) => { + creationDisabled, +}: NewTaskFormProps & { updateWorkflowStatusValue: (value: unknown) => void; creationDisabled: boolean }) => { const { title } = useSelector(selectCreateTask) return ( @@ -654,7 +656,12 @@ const NewTaskFooter = ({ } /> - +