From bbdcf01286281539eb3ef4ba39215031634572a1 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 27 Jan 2026 15:57:40 +0545 Subject: [PATCH 01/52] hotfix: increase max duration to 300s for validate count --- src/app/api/notification/validate-count/route.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/api/notification/validate-count/route.ts b/src/app/api/notification/validate-count/route.ts index 41b3cc501..791dbed65 100644 --- a/src/app/api/notification/validate-count/route.ts +++ b/src/app/api/notification/validate-count/route.ts @@ -1,4 +1,6 @@ import { withErrorHandler } from '@api/core/utils/withErrorHandler' import { validateCount } from '@api/notification/validate-count/validateCount.controller' +export const maxDuration = 300 + export const GET = withErrorHandler(validateCount) From ffa473d899f92fe295e298a03cd9fe25d94b75df Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Thu, 15 Jan 2026 16:28:33 +0545 Subject: [PATCH 02/52] feat(OUT-2914): store to attachment table when attachment is uploaded. - created a mechanism to support creating entries on attachments tables based on files/images uploaded on - task creation - task description update - subtask creation - subtask description update - comment/reply creation - comment/reply update - Entries on attachments table are done from api services when task/comments are created and from fronend client components when tasks/comments are edited. - Remaining: Clean deleted attachments from attachments table while clearing out scrap medias --- sentry.client.config.ts | 96 +++++++++---------- sentry.server.config.ts | 40 ++++---- src/app/api/tasks/tasksShared.service.ts | 23 ++++- src/app/detail/[task_id]/[user_type]/page.tsx | 10 +- src/app/detail/ui/ActivityWrapper.tsx | 6 +- src/app/detail/ui/Comments.tsx | 13 ++- src/app/detail/ui/NewTaskCard.tsx | 4 +- src/app/detail/ui/TaskEditor.tsx | 5 +- .../manage-templates/ui/NewTemplateCard.tsx | 4 +- .../manage-templates/ui/TemplateDetails.tsx | 4 +- src/app/manage-templates/ui/TemplateForm.tsx | 4 +- src/app/ui/Modal_NewTaskForm.tsx | 9 +- src/app/ui/NewTaskForm.tsx | 12 ++- src/components/cards/CommentCard.tsx | 9 +- src/components/cards/ReplyCard.tsx | 2 +- src/components/inputs/CommentInput.tsx | 8 +- src/components/inputs/ReplyInput.tsx | 3 +- src/redux/features/createTaskSlice.ts | 5 + src/types/dto/attachments.dto.ts | 20 ++-- src/utils/SupabaseActions.ts | 5 + .../{inlineImage.ts => attachmentUtils.ts} | 40 +++++++- 21 files changed, 211 insertions(+), 111 deletions(-) rename src/utils/{inlineImage.ts => attachmentUtils.ts} (56%) diff --git a/sentry.client.config.ts b/sentry.client.config.ts index a5d0e6fac..8e0fc39e3 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -2,55 +2,55 @@ // The config you add here will be used whenever a users loads a page in their browser. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ -import * as Sentry from "@sentry/nextjs"; +import * as Sentry from '@sentry/nextjs' -const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN; -const vercelEnv = process.env.NEXT_PUBLIC_VERCEL_ENV; -const isProd = process.env.NEXT_PUBLIC_VERCEL_ENV === "production"; +const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN +const vercelEnv = process.env.NEXT_PUBLIC_VERCEL_ENV +const isProd = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' if (dsn) { - Sentry.init({ - dsn, - - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: isProd ? 0.2 : 1, - profilesSampleRate: 0.1, - // NOTE: reducing sample only 10% of transactions in prod to get general trends instead of detailed and overfitted data - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - // You can remove this option if you're not planning to use the Sentry Session Replay feature: - // NOTE: Since session replay barely helps us anyways, getting rid of it to reduce some bundle size at least - // replaysOnErrorSampleRate: 1.0, - // replaysSessionSampleRate: 0, - integrations: [ - Sentry.browserTracingIntegration({ - beforeStartSpan: (e) => { - console.info("SentryBrowserTracingSpan", e.name); - return e; - }, - }), - // Sentry.replayIntegration({ - // Additional Replay configuration goes in here, for example: - // maskAllText: true, - // blockAllMedia: true, - // }), - ], - - // ignoreErrors: [/fetch failed/i], - ignoreErrors: [/fetch failed/i], - - beforeSend(event) { - if (!isProd && event.type === undefined) { - return null; - } - event.tags = { - ...event.tags, - // Adding additional app_env tag for cross-checking - app_env: isProd ? "production" : vercelEnv || "development", - }; - return event; - }, - }); + Sentry.init({ + dsn, + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: isProd ? 0.2 : 1, + profilesSampleRate: 0.1, + // NOTE: reducing sample only 10% of transactions in prod to get general trends instead of detailed and overfitted data + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + // You can remove this option if you're not planning to use the Sentry Session Replay feature: + // NOTE: Since session replay barely helps us anyways, getting rid of it to reduce some bundle size at least + // replaysOnErrorSampleRate: 1.0, + // replaysSessionSampleRate: 0, + integrations: [ + Sentry.browserTracingIntegration({ + beforeStartSpan: (e) => { + console.info('SentryBrowserTracingSpan', e.name) + return e + }, + }), + // Sentry.replayIntegration({ + // Additional Replay configuration goes in here, for example: + // maskAllText: true, + // blockAllMedia: true, + // }), + ], + + // ignoreErrors: [/fetch failed/i], + ignoreErrors: [/fetch failed/i], + + beforeSend(event) { + if (!isProd && event.type === undefined) { + return null + } + event.tags = { + ...event.tags, + // Adding additional app_env tag for cross-checking + app_env: isProd ? 'production' : vercelEnv || 'development', + } + return event + }, + }) } diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 174077b7b..517962e0e 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -2,31 +2,31 @@ // The config you add here will be used whenever the server handles a request. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ -import * as Sentry from "@sentry/nextjs"; +import * as Sentry from '@sentry/nextjs' -const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN; -const vercelEnv = process.env.NEXT_PUBLIC_VERCEL_ENV; -const isProd = process.env.NEXT_PUBLIC_VERCEL_ENV === "production"; +const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN +const vercelEnv = process.env.NEXT_PUBLIC_VERCEL_ENV +const isProd = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' if (dsn) { - Sentry.init({ - dsn, + Sentry.init({ + dsn, - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, - // Uncomment the line below to enable Spotlight (https://spotlightjs.com) - // spotlight: process.env.NODE_ENV === 'development', - ignoreErrors: [/fetch failed/i], + // Uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: process.env.NODE_ENV === 'development', + ignoreErrors: [/fetch failed/i], - beforeSend(event) { - if (!isProd && event.type === undefined) { - return null; - } - return event; - }, - }); + beforeSend(event) { + if (!isProd && event.type === undefined) { + return null + } + return event + }, + }) } diff --git a/src/app/api/tasks/tasksShared.service.ts b/src/app/api/tasks/tasksShared.service.ts index d5ca9abf8..d5863afaf 100644 --- a/src/app/api/tasks/tasksShared.service.ts +++ b/src/app/api/tasks/tasksShared.service.ts @@ -1,17 +1,20 @@ import { maxSubTaskDepth } from '@/constants/tasks' import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' import { InternalUsers, Uuid } from '@/types/common' +import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' import { CreateTaskRequest, CreateTaskRequestSchema, Viewers } from '@/types/dto/tasks.dto' +import { getFileNameFromPath } from '@/utils/attachmentUtils' import { buildLtree, buildLtreeNodeString } from '@/utils/ltree' import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' import { getSignedUrl } from '@/utils/signUrl' import { SupabaseActions } from '@/utils/SupabaseActions' +import APIError from '@api/core/exceptions/api' import { BaseService } from '@api/core/services/base.service' +import { UserRole } from '@api/core/types/user' import { AssigneeType, Prisma, PrismaClient, StateType, Task, TaskTemplate } from '@prisma/client' import httpStatus from 'http-status' import z from 'zod' -import APIError from '@api/core/exceptions/api' -import { UserRole } from '@api/core/types/user' +import { AttachmentsService } from '@api/attachments/attachments.service' //Base class with shared permission logic and methods that both tasks.service.ts and public.service.ts could use export abstract class TasksSharedService extends BaseService { @@ -384,6 +387,7 @@ export abstract class TasksSharedService extends BaseService { const newFilePaths: { originalSrc: string; newFilePath: string }[] = [] const copyAttachmentPromises: Promise[] = [] + const createAttachmentPayloads = [] const matches: { originalSrc: string; filePath: string; fileName: string }[] = [] while ((match = imgTagRegex.exec(htmlString)) !== null) { @@ -407,11 +411,26 @@ export abstract class TasksSharedService extends BaseService { for (const { originalSrc, filePath, fileName } of matches) { const newFilePath = `${this.user.workspaceId}/${task_id}/${fileName}` const supabaseActions = new SupabaseActions() + + const fileMetaData = await supabaseActions.getMetaData(filePath) + createAttachmentPayloads.push( + CreateAttachmentRequestSchema.parse({ + taskId: task_id, + filePath: newFilePath, + fileSize: fileMetaData?.size, + fileType: fileMetaData?.contentType, + fileName: getFileNameFromPath(newFilePath), + }), + ) copyAttachmentPromises.push(supabaseActions.moveAttachment(filePath, newFilePath)) newFilePaths.push({ originalSrc, newFilePath }) } await Promise.all(copyAttachmentPromises) + const attachmentService = new AttachmentsService(this.user) + if (createAttachmentPayloads.length) { + await attachmentService.createMultipleAttachments(createAttachmentPayloads) + } const signedUrlPromises = newFilePaths.map(async ({ originalSrc, newFilePath }) => { const newUrl = await getSignedUrl(newFilePath) diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index b3a1090aa..b0c9c2cdd 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -214,7 +214,15 @@ export default async function TaskDetailPage(props: { /> )} - + { + 'use server' + await postAttachment(token, postAttachmentPayload) + }} + /> void }) => { const { activeTask, assignee } = useSelector(selectTaskBoard) const { expandedComments } = useSelector(selectTaskDetails) @@ -234,6 +237,7 @@ export const ActivityWrapper = ({ task_id={task_id} stableId={z.string().parse(item.details.id) ?? item.id} optimisticUpdates={optimisticUpdates} + postAttachment={postAttachment} /> ) : Object.keys(item).length === 0 ? null : ( @@ -242,7 +246,7 @@ export const ActivityWrapper = ({ ))} - + )} diff --git a/src/app/detail/ui/Comments.tsx b/src/app/detail/ui/Comments.tsx index e7ceeffc3..c9fa4695b 100644 --- a/src/app/detail/ui/Comments.tsx +++ b/src/app/detail/ui/Comments.tsx @@ -8,6 +8,7 @@ import { LogResponse } from '@api/activity-logs/schemas/LogResponseSchema' import { Stack } from '@mui/material' import { useSelector } from 'react-redux' import { VerticalLine } from './styledComponent' +import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' interface Prop { comment: LogResponse @@ -16,9 +17,18 @@ interface Prop { task_id: string stableId: string optimisticUpdates: OptimisticUpdate[] + postAttachment: (postAttachmentPayload: CreateAttachmentRequest) => void } -export const Comments = ({ comment, createComment, deleteComment, task_id, stableId, optimisticUpdates }: Prop) => { +export const Comments = ({ + comment, + createComment, + deleteComment, + task_id, + stableId, + optimisticUpdates, + postAttachment, +}: Prop) => { const { assignee } = useSelector(selectTaskBoard) const commentInitiator = assignee.find((assignee) => assignee.id == comment.userId) return ( @@ -42,6 +52,7 @@ export const Comments = ({ comment, createComment, deleteComment, task_id, stabl task_id={task_id} optimisticUpdates={optimisticUpdates} commentInitiator={commentInitiator} + postAttachment={postAttachment} /> diff --git a/src/app/detail/ui/NewTaskCard.tsx b/src/app/detail/ui/NewTaskCard.tsx index c9232febc..dca9ab8a9 100644 --- a/src/app/detail/ui/NewTaskCard.tsx +++ b/src/app/detail/ui/NewTaskCard.tsx @@ -22,7 +22,7 @@ import { CreateTaskRequest, Viewers } from '@/types/dto/tasks.dto' import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto' import { FilterByOptions, IAssigneeCombined, InputValue, ITemplate, UserIds } from '@/types/interfaces' import { getAssigneeName, UserIdsType } from '@/utils/assignee' -import { deleteEditorAttachmentsHandler, uploadImageHandler } from '@/utils/inlineImage' +import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { getSelectedUserIds, getSelectedViewerIds, @@ -105,7 +105,7 @@ export const NewTaskCard = ({ const uploadFn = token && tokenPayload?.workspaceId - ? (file: File) => uploadImageHandler(file, token, tokenPayload.workspaceId, null) + ? (file: File) => uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null) : undefined const todoWorkflowState = workflowStates.find((el) => el.key === 'todo') || workflowStates[0] diff --git a/src/app/detail/ui/TaskEditor.tsx b/src/app/detail/ui/TaskEditor.tsx index edca9e837..e936a8b8d 100644 --- a/src/app/detail/ui/TaskEditor.tsx +++ b/src/app/detail/ui/TaskEditor.tsx @@ -14,7 +14,7 @@ import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' import { TaskResponse } from '@/types/dto/tasks.dto' import { UserType } from '@/types/interfaces' import { getDeleteMessage } from '@/utils/dialogMessages' -import { deleteEditorAttachmentsHandler, uploadImageHandler } from '@/utils/inlineImage' +import { deleteEditorAttachmentsHandler, getAttachmentPayload, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { Box } from '@mui/material' import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' @@ -138,7 +138,8 @@ export const TaskEditor = ({ const uploadFn = token ? async (file: File) => { setActiveUploads((prev) => prev + 1) - const fileUrl = await uploadImageHandler(file, token ?? '', task.workspaceId, task_id) + const fileUrl = await uploadAttachmentHandler(file, token ?? '', task.workspaceId, task_id) + fileUrl && postAttachment(getAttachmentPayload(fileUrl, file, task_id)) setActiveUploads((prev) => prev - 1) return fileUrl } diff --git a/src/app/manage-templates/ui/NewTemplateCard.tsx b/src/app/manage-templates/ui/NewTemplateCard.tsx index e83fd6d2d..230872782 100644 --- a/src/app/manage-templates/ui/NewTemplateCard.tsx +++ b/src/app/manage-templates/ui/NewTemplateCard.tsx @@ -13,7 +13,7 @@ import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { selectCreateTemplate } from '@/redux/features/templateSlice' import { CreateTemplateRequest } from '@/types/dto/templates.dto' import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto' -import { deleteEditorAttachmentsHandler, uploadImageHandler } from '@/utils/inlineImage' +import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { Box, Stack, Typography } from '@mui/material' import { useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' @@ -62,7 +62,7 @@ export const NewTemplateCard = ({ } const uploadFn = token && tokenPayload?.workspaceId - ? (file: File) => uploadImageHandler(file, token, tokenPayload.workspaceId, null, 'templates') + ? (file: File) => uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null, 'templates') : undefined const todoWorkflowState = workflowStates.find((el) => el.key === 'todo') || workflowStates[0] diff --git a/src/app/manage-templates/ui/TemplateDetails.tsx b/src/app/manage-templates/ui/TemplateDetails.tsx index aa3fe5103..2feedbc55 100644 --- a/src/app/manage-templates/ui/TemplateDetails.tsx +++ b/src/app/manage-templates/ui/TemplateDetails.tsx @@ -11,7 +11,7 @@ import { clearTemplateFields, selectCreateTemplate } from '@/redux/features/temp import store from '@/redux/store' import { CreateTemplateRequest } from '@/types/dto/templates.dto' import { ITemplate } from '@/types/interfaces' -import { deleteEditorAttachmentsHandler, uploadImageHandler } from '@/utils/inlineImage' +import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { Box } from '@mui/material' import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' @@ -112,7 +112,7 @@ export default function TemplateDetails({ const uploadFn = token ? async (file: File) => { setActiveUploads((prev) => prev + 1) - const fileUrl = await uploadImageHandler(file, token ?? '', template.workspaceId, template_id, 'templates') + const fileUrl = await uploadAttachmentHandler(file, token ?? '', template.workspaceId, template_id, 'templates') setActiveUploads((prev) => prev - 1) return fileUrl } diff --git a/src/app/manage-templates/ui/TemplateForm.tsx b/src/app/manage-templates/ui/TemplateForm.tsx index ac902a706..91b49f7fd 100644 --- a/src/app/manage-templates/ui/TemplateForm.tsx +++ b/src/app/manage-templates/ui/TemplateForm.tsx @@ -25,7 +25,7 @@ import { useHandleSelectorComponent } from '@/hooks/useHandleSelectorComponent' import { SelectorType } from '@/components/inputs/Selector' import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' -import { deleteEditorAttachmentsHandler, uploadImageHandler } from '@/utils/inlineImage' +import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' import AttachmentLayout from '@/components/AttachmentLayout' import { StyledModal } from '@/app/detail/ui/styledComponent' @@ -84,7 +84,7 @@ const NewTemplateFormInputs = () => { const uploadFn = token && tokenPayload?.workspaceId - ? async (file: File) => uploadImageHandler(file, token, tokenPayload.workspaceId, null, 'templates') + ? async (file: File) => uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null, 'templates') : undefined const todoWorkflowState = workflowStates.find((el) => el.key === 'todo') || workflowStates[0] diff --git a/src/app/ui/Modal_NewTaskForm.tsx b/src/app/ui/Modal_NewTaskForm.tsx index 043958f98..b8bac9a44 100644 --- a/src/app/ui/Modal_NewTaskForm.tsx +++ b/src/app/ui/Modal_NewTaskForm.tsx @@ -93,16 +93,9 @@ export const ModalNewTaskForm = ({ const isSubTaskDisabled = disableSubtaskTemplates store.dispatch(clearCreateTaskFields({ isFilterOn: !checkEmptyAssignee(filterOptions[FilterOptions.ASSIGNEE]) })) - const createdTask = await handleCreate(token as string, CreateTaskRequestSchema.parse(payload), { + await handleCreate(token as string, CreateTaskRequestSchema.parse(payload), { disableSubtaskTemplates: isSubTaskDisabled, }) - const toUploadAttachments: CreateAttachmentRequest[] = attachments.map((el) => { - return { - ...el, - taskId: createdTask.id, - } - }) - await handleCreateMultipleAttachments(toUploadAttachments) }} handleClose={handleModalClose} /> diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index 7ec5a9783..e1d459fad 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -19,6 +19,7 @@ import { useHandleSelectorComponent } from '@/hooks/useHandleSelectorComponent' import { CloseIcon, PersonIconSmall, TempalteIconMd } from '@/icons' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { + addAttachment, selectCreateTask, setAllCreateTaskFields, setAppliedDescription, @@ -43,7 +44,7 @@ import { } from '@/types/interfaces' import { checkEmptyAssignee, emptyAssignee, getAssigneeName } from '@/utils/assignee' import { getAssigneeTypeCorrected } from '@/utils/getAssigneeTypeCorrected' -import { deleteEditorAttachmentsHandler, uploadImageHandler } from '@/utils/inlineImage' +import { deleteEditorAttachmentsHandler, getAttachmentPayload, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { getSelectedUserIds, getSelectedViewerIds, @@ -56,6 +57,9 @@ import { marked } from 'marked' import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { Tapwrite } from 'tapwrite' +import { UserRole } from '@/app/api/core/types/user' +import { GhostBtn } from '@/components/buttons/GhostBtn' +import { v4 as uuidv4 } from 'uuid' interface NewTaskFormInputsProps { isEditorReadonly?: boolean @@ -562,7 +566,11 @@ const NewTaskFormInputs = ({ isEditorReadonly }: NewTaskFormInputsProps) => { const uploadFn = token && tokenPayload?.workspaceId - ? (file: File) => uploadImageHandler(file, token, tokenPayload.workspaceId, null) + ? async (file: File) => { + const fileUrl = await uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null) + fileUrl && store.dispatch(addAttachment(getAttachmentPayload(fileUrl, file, uuidv4()))) + return fileUrl + } : undefined return ( diff --git a/src/components/cards/CommentCard.tsx b/src/components/cards/CommentCard.tsx index 8b26ac56c..759513098 100644 --- a/src/components/cards/CommentCard.tsx +++ b/src/components/cards/CommentCard.tsx @@ -30,7 +30,7 @@ import { IAssigneeCombined } from '@/types/interfaces' import { getAssigneeName } from '@/utils/assignee' import { fetcher } from '@/utils/fetcher' import { getTimeDifference } from '@/utils/getTimeDifference' -import { deleteEditorAttachmentsHandler, uploadImageHandler } from '@/utils/inlineImage' +import { deleteEditorAttachmentsHandler, getAttachmentPayload, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { checkOptimisticStableId, OptimisticUpdate } from '@/utils/optimisticCommentUtils' import { ReplyResponse } from '@api/activity-logs/schemas/CommentAddedSchema' @@ -42,6 +42,7 @@ import { TransitionGroup } from 'react-transition-group' import useSWRMutation from 'swr/mutation' import { Tapwrite } from 'tapwrite' import { z } from 'zod' +import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' export const CommentCard = ({ comment, @@ -51,6 +52,7 @@ export const CommentCard = ({ optimisticUpdates, commentInitiator, 'data-comment-card': dataCommentCard, //for selection of the element while highlighting the container in notification + postAttachment, }: { comment: LogResponse createComment: (postCommentPayload: CreateComment) => void @@ -59,6 +61,7 @@ export const CommentCard = ({ optimisticUpdates: OptimisticUpdate[] commentInitiator: IAssigneeCombined | undefined 'data-comment-card'?: string + postAttachment: (postAttachmentPayload: CreateAttachmentRequest) => void }) => { const [showReply, setShowReply] = useState(false) const [isHovered, setIsHovered] = useState(false) @@ -107,8 +110,10 @@ export const CommentCard = ({ const uploadFn = token ? async (file: File) => { + const commentId = z.string().parse(comment.details.id) if (activeTask) { - const fileUrl = await uploadImageHandler(file, token, activeTask.workspaceId, task_id) + const fileUrl = await uploadAttachmentHandler(file, token, activeTask.workspaceId, commentId, 'comments', task_id) + fileUrl && postAttachment(getAttachmentPayload(fileUrl, file, commentId, 'comments')) return fileUrl } } diff --git a/src/components/cards/ReplyCard.tsx b/src/components/cards/ReplyCard.tsx index 38910e4b4..49d4796fa 100644 --- a/src/components/cards/ReplyCard.tsx +++ b/src/components/cards/ReplyCard.tsx @@ -19,7 +19,7 @@ import { UpdateComment } from '@/types/dto/comment.dto' import { IAssigneeCombined } from '@/types/interfaces' import { getAssigneeName } from '@/utils/assignee' import { getTimeDifference } from '@/utils/getTimeDifference' -import { deleteEditorAttachmentsHandler } from '@/utils/inlineImage' +import { deleteEditorAttachmentsHandler } from '@/utils/attachmentUtils' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { Box, Stack } from '@mui/material' import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' diff --git a/src/components/inputs/CommentInput.tsx b/src/components/inputs/CommentInput.tsx index 9c9d53eb3..33dffc068 100644 --- a/src/components/inputs/CommentInput.tsx +++ b/src/components/inputs/CommentInput.tsx @@ -8,9 +8,10 @@ import { MAX_UPLOAD_LIMIT } from '@/constants/attachments' import { useWindowWidth } from '@/hooks/useWindowWidth' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' +import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' import { CreateComment } from '@/types/dto/comment.dto' +import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { getMentionsList } from '@/utils/getMentionList' -import { deleteEditorAttachmentsHandler, uploadImageHandler } from '@/utils/inlineImage' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { Stack } from '@mui/material' import { useEffect, useRef, useState } from 'react' @@ -20,9 +21,10 @@ import { Tapwrite } from 'tapwrite' interface Prop { createComment: (postCommentPayload: CreateComment) => void task_id: string + postAttachment: (postAttachmentPayload: CreateAttachmentRequest) => void } -export const CommentInput = ({ createComment, task_id }: Prop) => { +export const CommentInput = ({ createComment, task_id, postAttachment }: Prop) => { const [detail, setDetail] = useState('') const [isListOrMenuActive, setIsListOrMenuActive] = useState(false) const { tokenPayload } = useSelector(selectAuthDetails) @@ -87,7 +89,7 @@ export const CommentInput = ({ createComment, task_id }: Prop) => { const uploadFn = token ? async (file: File) => { if (activeTask) { - const fileUrl = await uploadImageHandler(file, token ?? '', activeTask.workspaceId, task_id) + const fileUrl = await uploadAttachmentHandler(file, token ?? '', activeTask.workspaceId, task_id) return fileUrl } } diff --git a/src/components/inputs/ReplyInput.tsx b/src/components/inputs/ReplyInput.tsx index b4e6aadba..8aa340209 100644 --- a/src/components/inputs/ReplyInput.tsx +++ b/src/components/inputs/ReplyInput.tsx @@ -6,7 +6,8 @@ import { useWindowWidth } from '@/hooks/useWindowWidth' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { CreateComment } from '@/types/dto/comment.dto' -import { deleteEditorAttachmentsHandler } from '@/utils/inlineImage' +import { getMentionsList } from '@/utils/getMentionList' +import { deleteEditorAttachmentsHandler } from '@/utils/attachmentUtils' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { Box, Stack } from '@mui/material' import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react' diff --git a/src/redux/features/createTaskSlice.ts b/src/redux/features/createTaskSlice.ts index 0e938a388..c1c1a4741 100644 --- a/src/redux/features/createTaskSlice.ts +++ b/src/redux/features/createTaskSlice.ts @@ -75,6 +75,10 @@ const createTaskSlice = createSlice({ state.attachments = state.attachments.filter((el) => el.filePath !== attachment.filePath) }, + addAttachment: (state, action: { payload: CreateAttachmentRequest }) => { + state.attachments.push(action.payload) + }, + setCreateTaskFields: ( state, action: { payload: { targetField: keyof IInitialState; value: IInitialState[keyof IInitialState] } }, @@ -147,6 +151,7 @@ export const { setAppliedDescription, setAppliedTitle, setAllCreateTaskFields, + addAttachment, } = createTaskSlice.actions export default createTaskSlice.reducer diff --git a/src/types/dto/attachments.dto.ts b/src/types/dto/attachments.dto.ts index ed6ac4695..e87c3edf2 100644 --- a/src/types/dto/attachments.dto.ts +++ b/src/types/dto/attachments.dto.ts @@ -1,13 +1,19 @@ import { boolean, z } from 'zod' import { FileTypes } from '@/types/interfaces' -export const CreateAttachmentRequestSchema = z.object({ - taskId: z.string(), - filePath: z.string(), - fileSize: z.number(), - fileType: z.string(), - fileName: z.string(), -}) +export const CreateAttachmentRequestSchema = z + .object({ + taskId: z.string().uuid().optional(), + commentId: z.string().uuid().optional(), + filePath: z.string(), + fileSize: z.number(), + fileType: z.string(), + fileName: z.string(), + }) + .refine((data) => !!data.taskId !== !!data.commentId, { + message: 'Provide either taskId or commentId, but not both', + path: ['taskId', 'commentId'], + }) //XOR LOGIC for taskId and commentId. export type CreateAttachmentRequest = z.infer diff --git a/src/utils/SupabaseActions.ts b/src/utils/SupabaseActions.ts index 3856608e1..b78ed33a9 100644 --- a/src/utils/SupabaseActions.ts +++ b/src/utils/SupabaseActions.ts @@ -14,6 +14,11 @@ export class SupabaseActions extends SupabaseService { return data } + async getMetaData(filePath: string) { + const { data, error } = await this.supabase.storage.from(supabaseBucket).info(filePath) + return data + } + async uploadAttachment(file: File, signedUrl: ISignedUrlUpload, task_id: string | null) { let filePayload const { data, error } = await this.supabase.storage diff --git a/src/utils/inlineImage.ts b/src/utils/attachmentUtils.ts similarity index 56% rename from src/utils/inlineImage.ts rename to src/utils/attachmentUtils.ts index cd9af1a4b..d218c0c2d 100644 --- a/src/utils/inlineImage.ts +++ b/src/utils/attachmentUtils.ts @@ -6,25 +6,38 @@ import { ScrapMediaRequest } from '@/types/common' import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' import { getSignedUrlFile, getSignedUrlUpload } from '@/app/(home)/actions' +import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' -const buildFilePath = (workspaceId: string, type: 'tasks' | 'templates', entityId: string | null) => { +const buildFilePath = ( + workspaceId: string, + type: 'tasks' | 'templates' | 'comments', + entityId: string | null, + parentTaskId?: string, +) => { if (type === 'tasks') { return entityId ? `/${workspaceId}/${entityId}` : `/${workspaceId}` + } else if (type === 'comments') { + return `/${workspaceId}/${parentTaskId}/comments${entityId ? `/${entityId}` : ''}` } return `/${workspaceId}/templates${entityId ? `/${entityId}` : ''}` } -export const uploadImageHandler = async ( +export const uploadAttachmentHandler = async ( file: File, token: string, workspaceId: string, entityId: string | null, - type: 'tasks' | 'templates' = 'tasks', + type: 'tasks' | 'templates' | 'comments' = 'tasks', + parentTaskId?: string, ): Promise => { const supabaseActions = new SupabaseActions() const fileName = generateRandomString(file.name) - const signedUrl: ISignedUrlUpload = await getSignedUrlUpload(token, fileName, buildFilePath(workspaceId, type, entityId)) + const signedUrl: ISignedUrlUpload = await getSignedUrlUpload( + token, + fileName, + buildFilePath(workspaceId, type, entityId, parentTaskId), + ) const { filePayload, error } = await supabaseActions.uploadAttachment(file, signedUrl, entityId) @@ -55,3 +68,22 @@ export const deleteEditorAttachmentsHandler = async ( postScrapMedia(token, payload) } } + +export const getAttachmentPayload = (fileUrl: string, file: File, id: string, entity: 'tasks' | 'comments' = 'tasks') => { + const filePath = getFilePathFromUrl(fileUrl) + + const payload = entity === 'comments' ? { commentId: id } : { taskId: id } + + return CreateAttachmentRequestSchema.parse({ + ...payload, + filePath, + fileSize: file.size, + fileType: file.type, + fileName: file.name, + }) +} + +export const getFileNameFromPath = (path: string): string => { + const segments = path.split('/').filter(Boolean) + return segments[segments.length - 1] || '' +} From 69387a9241ad2fa5c65b29a16be86f62f6672141 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Fri, 16 Jan 2026 17:15:48 +0545 Subject: [PATCH 03/52] feat(OUT-2914): attachments table create and delete entries progress - added a mechanism to extract attachments when comment/reply is created, move them to their respective folder and create an entry on attachments table. - Cleaning deleted attachments from attachments table while clearing out scrap medias. --- src/app/api/comment/comment.service.ts | 100 ++++++++++++++++++ .../scrap-medias/scrap-medias.service.ts | 1 + .../manage-templates/ui/NewTemplateCard.tsx | 3 +- .../manage-templates/ui/TemplateDetails.tsx | 10 +- src/app/manage-templates/ui/TemplateForm.tsx | 4 +- src/app/ui/Modal_NewTaskForm.tsx | 1 - src/app/ui/NewTaskForm.tsx | 7 +- src/components/cards/CommentCard.tsx | 22 +++- src/redux/features/createTaskSlice.ts | 14 --- src/types/interfaces.ts | 6 ++ src/utils/attachmentUtils.ts | 19 ++-- 11 files changed, 150 insertions(+), 37 deletions(-) diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 2e43850a0..adf46c538 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -1,8 +1,12 @@ import { sendCommentCreateNotifications } from '@/jobs/notifications' import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' import { InitiatedEntity } from '@/types/common' +import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' import { CreateComment, UpdateComment } from '@/types/dto/comment.dto' import { getArrayDifference, getArrayIntersection } from '@/utils/array' +import { getFileNameFromPath } from '@/utils/attachmentUtils' +import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' +import { SupabaseActions } from '@/utils/SupabaseActions' import { CommentAddedSchema } from '@api/activity-logs/schemas/CommentAddedSchema' import { ActivityLogger } from '@api/activity-logs/services/activity-logger.service' import { CommentRepository } from '@api/comment/comment.repository' @@ -15,6 +19,8 @@ import { TasksService } from '@api/tasks/tasks.service' import { ActivityType, Comment, CommentInitiator } from '@prisma/client' import httpStatus from 'http-status' import { z } from 'zod' +import { AttachmentsService } from '../attachments/attachments.service' +import { getSignedUrl } from '@/utils/signUrl' export class CommentService extends BaseService { async create(data: CreateComment) { @@ -57,6 +63,21 @@ export class CommentService extends BaseService { }), ) await sendCommentCreateNotifications.trigger({ user: this.user, task, comment }) + try { + if (comment.content) { + const newContent = await this.updateCommentIdOfAttachmentsAfterCreation(comment.content, data.taskId, comment.id) + await this.db.comment.update({ + where: { id: comment.id }, + data: { + content: newContent, + }, + }) + console.info('CommentService#createComment | Comment content attachments updated for comment ID:', comment.id) + } + } catch (e: unknown) { + await this.db.comment.delete({ where: { id: comment.id } }) + console.error('CommentService#createComment | Rolling back comment creation', e) + } } else { const tasksService = new TasksService(this.user) await Promise.all([ @@ -266,4 +287,83 @@ export class CommentService extends BaseService { return { ...comment, initiator } }) } + + private async updateCommentIdOfAttachmentsAfterCreation(htmlString: string, task_id: string, commentId: string) { + const imgTagRegex = /]*src="([^"]+)"[^>]*>/g //expression used to match all img srcs in provided HTML string. + const attachmentTagRegex = /<\s*[a-zA-Z]+\s+[^>]*data-type="attachment"[^>]*src="([^"]+)"[^>]*>/g //expression used to match all attachment srcs in provided HTML string. + let match + const replacements: { originalSrc: string; newUrl: string }[] = [] + + const newFilePaths: { originalSrc: string; newFilePath: string }[] = [] + const copyAttachmentPromises: Promise[] = [] + const createAttachmentPayloads = [] + const matches: { originalSrc: string; filePath: string; fileName: string }[] = [] + + while ((match = imgTagRegex.exec(htmlString)) !== null) { + const originalSrc = match[1] + const filePath = getFilePathFromUrl(originalSrc) + const fileName = filePath?.split('/').pop() + if (filePath && fileName) { + matches.push({ originalSrc, filePath, fileName }) + } + } + + while ((match = attachmentTagRegex.exec(htmlString)) !== null) { + const originalSrc = match[1] + const filePath = getFilePathFromUrl(originalSrc) + const fileName = filePath?.split('/').pop() + if (filePath && fileName) { + matches.push({ originalSrc, filePath, fileName }) + } + } + + for (const { originalSrc, filePath, fileName } of matches) { + const newFilePath = `${this.user.workspaceId}/${task_id}/comments/${commentId}/${fileName}` + const supabaseActions = new SupabaseActions() + + const fileMetaData = await supabaseActions.getMetaData(filePath) + createAttachmentPayloads.push( + CreateAttachmentRequestSchema.parse({ + commentId: commentId, + filePath: newFilePath, + fileSize: fileMetaData?.size, + fileType: fileMetaData?.contentType, + fileName: getFileNameFromPath(newFilePath), + }), + ) + copyAttachmentPromises.push(supabaseActions.moveAttachment(filePath, newFilePath)) + newFilePaths.push({ originalSrc, newFilePath }) + } + + await Promise.all(copyAttachmentPromises) + const attachmentService = new AttachmentsService(this.user) + if (createAttachmentPayloads.length) { + await attachmentService.createMultipleAttachments(createAttachmentPayloads) + } + + const signedUrlPromises = newFilePaths.map(async ({ originalSrc, newFilePath }) => { + const newUrl = await getSignedUrl(newFilePath) + if (newUrl) { + replacements.push({ originalSrc, newUrl }) + } + }) + + await Promise.all(signedUrlPromises) + + for (const { originalSrc, newUrl } of replacements) { + htmlString = htmlString.replace(originalSrc, newUrl) + } + // const filePaths = newFilePaths.map(({ newFilePath }) => newFilePath) + // await this.db.scrapMedia.updateMany({ + // where: { + // filePath: { + // in: filePaths, + // }, + // }, + // data: { + // taskId: task_id, + // }, + // }) //todo: add support for commentId in scrapMedias. + return htmlString + } } diff --git a/src/app/api/workers/scrap-medias/scrap-medias.service.ts b/src/app/api/workers/scrap-medias/scrap-medias.service.ts index bc465c954..b9c25e3e5 100644 --- a/src/app/api/workers/scrap-medias/scrap-medias.service.ts +++ b/src/app/api/workers/scrap-medias/scrap-medias.service.ts @@ -83,6 +83,7 @@ export class ScrapMediaService { console.error(error) throw new APIError(404, 'unable to delete some date from supabase') } + await db.attachment.deleteMany({ where: { filePath: { in: scrapMediasToDeleteFromBucket } } }) } if (scrapMediasToDelete.length !== 0) { const idsToDelete = scrapMediasToDelete.map((id) => `'${id}'`).join(', ') diff --git a/src/app/manage-templates/ui/NewTemplateCard.tsx b/src/app/manage-templates/ui/NewTemplateCard.tsx index 230872782..7a11b52ea 100644 --- a/src/app/manage-templates/ui/NewTemplateCard.tsx +++ b/src/app/manage-templates/ui/NewTemplateCard.tsx @@ -13,6 +13,7 @@ import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { selectCreateTemplate } from '@/redux/features/templateSlice' import { CreateTemplateRequest } from '@/types/dto/templates.dto' import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto' +import { AttachmentTypes } from '@/types/interfaces' import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { Box, Stack, Typography } from '@mui/material' import { useEffect, useRef, useState } from 'react' @@ -62,7 +63,7 @@ export const NewTemplateCard = ({ } const uploadFn = token && tokenPayload?.workspaceId - ? (file: File) => uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null, 'templates') + ? (file: File) => uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null, AttachmentTypes.TEMPLATE) : undefined const todoWorkflowState = workflowStates.find((el) => el.key === 'todo') || workflowStates[0] diff --git a/src/app/manage-templates/ui/TemplateDetails.tsx b/src/app/manage-templates/ui/TemplateDetails.tsx index 2feedbc55..c8d8d85c2 100644 --- a/src/app/manage-templates/ui/TemplateDetails.tsx +++ b/src/app/manage-templates/ui/TemplateDetails.tsx @@ -10,7 +10,7 @@ import { selectTaskDetails, setOpenImage, setShowConfirmDeleteModal } from '@/re import { clearTemplateFields, selectCreateTemplate } from '@/redux/features/templateSlice' import store from '@/redux/store' import { CreateTemplateRequest } from '@/types/dto/templates.dto' -import { ITemplate } from '@/types/interfaces' +import { AttachmentTypes, ITemplate } from '@/types/interfaces' import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { Box } from '@mui/material' import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react' @@ -112,7 +112,13 @@ export default function TemplateDetails({ const uploadFn = token ? async (file: File) => { setActiveUploads((prev) => prev + 1) - const fileUrl = await uploadAttachmentHandler(file, token ?? '', template.workspaceId, template_id, 'templates') + const fileUrl = await uploadAttachmentHandler( + file, + token ?? '', + template.workspaceId, + template_id, + AttachmentTypes.TEMPLATE, + ) setActiveUploads((prev) => prev - 1) return fileUrl } diff --git a/src/app/manage-templates/ui/TemplateForm.tsx b/src/app/manage-templates/ui/TemplateForm.tsx index 91b49f7fd..a7104f4dd 100644 --- a/src/app/manage-templates/ui/TemplateForm.tsx +++ b/src/app/manage-templates/ui/TemplateForm.tsx @@ -8,7 +8,7 @@ import { AttachmentIcon } from '@/icons' import store from '@/redux/store' import { Close } from '@mui/icons-material' import { Box, Stack, Typography, styled } from '@mui/material' -import { createTemplateErrors, TargetMethod } from '@/types/interfaces' +import { AttachmentTypes, createTemplateErrors, TargetMethod } from '@/types/interfaces' import { useSelector } from 'react-redux' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { @@ -84,7 +84,7 @@ const NewTemplateFormInputs = () => { const uploadFn = token && tokenPayload?.workspaceId - ? async (file: File) => uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null, 'templates') + ? async (file: File) => uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null, AttachmentTypes.TEMPLATE) : undefined const todoWorkflowState = workflowStates.find((el) => el.key === 'todo') || workflowStates[0] diff --git a/src/app/ui/Modal_NewTaskForm.tsx b/src/app/ui/Modal_NewTaskForm.tsx index b8bac9a44..89bfea93d 100644 --- a/src/app/ui/Modal_NewTaskForm.tsx +++ b/src/app/ui/Modal_NewTaskForm.tsx @@ -31,7 +31,6 @@ export const ModalNewTaskForm = ({ description, workflowStateId, userIds, - attachments, dueDate, showModal, templateId, diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index e1d459fad..7a704b526 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -19,7 +19,6 @@ import { useHandleSelectorComponent } from '@/hooks/useHandleSelectorComponent' import { CloseIcon, PersonIconSmall, TempalteIconMd } from '@/icons' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { - addAttachment, selectCreateTask, setAllCreateTaskFields, setAppliedDescription, @@ -43,8 +42,8 @@ import { UserIds, } from '@/types/interfaces' import { checkEmptyAssignee, emptyAssignee, getAssigneeName } from '@/utils/assignee' +import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { getAssigneeTypeCorrected } from '@/utils/getAssigneeTypeCorrected' -import { deleteEditorAttachmentsHandler, getAttachmentPayload, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { getSelectedUserIds, getSelectedViewerIds, @@ -57,9 +56,6 @@ import { marked } from 'marked' import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { Tapwrite } from 'tapwrite' -import { UserRole } from '@/app/api/core/types/user' -import { GhostBtn } from '@/components/buttons/GhostBtn' -import { v4 as uuidv4 } from 'uuid' interface NewTaskFormInputsProps { isEditorReadonly?: boolean @@ -568,7 +564,6 @@ const NewTaskFormInputs = ({ isEditorReadonly }: NewTaskFormInputsProps) => { token && tokenPayload?.workspaceId ? async (file: File) => { const fileUrl = await uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null) - fileUrl && store.dispatch(addAttachment(getAttachmentPayload(fileUrl, file, uuidv4()))) return fileUrl } : undefined diff --git a/src/components/cards/CommentCard.tsx b/src/components/cards/CommentCard.tsx index 759513098..d0adf39e4 100644 --- a/src/components/cards/CommentCard.tsx +++ b/src/components/cards/CommentCard.tsx @@ -26,7 +26,7 @@ import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { selectTaskDetails, setExpandedComments, setOpenImage } from '@/redux/features/taskDetailsSlice' import store from '@/redux/store' import { CommentResponse, CreateComment, UpdateComment } from '@/types/dto/comment.dto' -import { IAssigneeCombined } from '@/types/interfaces' +import { AttachmentTypes, IAssigneeCombined } from '@/types/interfaces' import { getAssigneeName } from '@/utils/assignee' import { fetcher } from '@/utils/fetcher' import { getTimeDifference } from '@/utils/getTimeDifference' @@ -108,12 +108,26 @@ export const CommentCard = ({ return () => clearInterval(intervalId) }, [comment.createdAt]) + const commentIdRef = useRef(comment.details.id) + + useEffect(() => { + commentIdRef.current = comment.details.id + }, [comment.details.id]) //done because tapwrite only takes uploadFn once on mount where commentId will be temp from optimistic update. So we need an actual commentId for uploadFn to work. + const uploadFn = token ? async (file: File) => { - const commentId = z.string().parse(comment.details.id) + const commentIdFromRef = commentIdRef.current + const commentId = z.string().parse(commentIdFromRef) if (activeTask) { - const fileUrl = await uploadAttachmentHandler(file, token, activeTask.workspaceId, commentId, 'comments', task_id) - fileUrl && postAttachment(getAttachmentPayload(fileUrl, file, commentId, 'comments')) + const fileUrl = await uploadAttachmentHandler( + file, + token, + activeTask.workspaceId, + commentId, + AttachmentTypes.COMMENT, + task_id, + ) + fileUrl && postAttachment(getAttachmentPayload(fileUrl, file, commentId, AttachmentTypes.COMMENT)) return fileUrl } } diff --git a/src/redux/features/createTaskSlice.ts b/src/redux/features/createTaskSlice.ts index c1c1a4741..18d5e81f1 100644 --- a/src/redux/features/createTaskSlice.ts +++ b/src/redux/features/createTaskSlice.ts @@ -16,7 +16,6 @@ interface IInitialState { title: string description: string workflowStateId: string - attachments: CreateAttachmentRequest[] dueDate: DateString | null errors: IErrors appliedTitle: string | null @@ -34,7 +33,6 @@ const initialState: IInitialState = { title: '', workflowStateId: '', description: '', - attachments: [], dueDate: null, errors: { [CreateTaskErrors.TITLE]: false, @@ -70,15 +68,6 @@ const createTaskSlice = createSlice({ state.activeWorkflowStateId = action.payload }, - removeOneAttachment: (state, action: { payload: { attachment: CreateAttachmentRequest } }) => { - const { attachment } = action.payload - state.attachments = state.attachments.filter((el) => el.filePath !== attachment.filePath) - }, - - addAttachment: (state, action: { payload: CreateAttachmentRequest }) => { - state.attachments.push(action.payload) - }, - setCreateTaskFields: ( state, action: { payload: { targetField: keyof IInitialState; value: IInitialState[keyof IInitialState] } }, @@ -113,7 +102,6 @@ const createTaskSlice = createSlice({ } } state.viewers = [] - state.attachments = [] state.dueDate = null state.errors = { [CreateTaskErrors.TITLE]: false, @@ -146,12 +134,10 @@ export const { setActiveWorkflowStateId, setCreateTaskFields, clearCreateTaskFields, - removeOneAttachment, setErrors, setAppliedDescription, setAppliedTitle, setAllCreateTaskFields, - addAttachment, } = createTaskSlice.actions export default createTaskSlice.reducer diff --git a/src/types/interfaces.ts b/src/types/interfaces.ts index c96f072df..8135febe8 100644 --- a/src/types/interfaces.ts +++ b/src/types/interfaces.ts @@ -83,6 +83,12 @@ export enum UserIds { COMPANY_ID = 'companyId', } +export enum AttachmentTypes { + TASK = 'tasks', + TEMPLATE = 'templates', + COMMENT = 'comments', +} + export type IFilterOptions = { [key in FilterOptions]: key extends FilterOptions.ASSIGNEE ? UserIdsType diff --git a/src/utils/attachmentUtils.ts b/src/utils/attachmentUtils.ts index d218c0c2d..b3aa2fa09 100644 --- a/src/utils/attachmentUtils.ts +++ b/src/utils/attachmentUtils.ts @@ -1,4 +1,4 @@ -import { ISignedUrlUpload } from '@/types/interfaces' +import { AttachmentTypes, ISignedUrlUpload } from '@/types/interfaces' import { generateRandomString } from '@/utils/generateRandomString' import { SupabaseActions } from '@/utils/SupabaseActions' import { postScrapMedia } from '@/app/detail/[task_id]/[user_type]/actions' @@ -10,13 +10,13 @@ import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' const buildFilePath = ( workspaceId: string, - type: 'tasks' | 'templates' | 'comments', + type: AttachmentTypes[keyof AttachmentTypes], entityId: string | null, parentTaskId?: string, ) => { - if (type === 'tasks') { + if (type === AttachmentTypes.TASK) { return entityId ? `/${workspaceId}/${entityId}` : `/${workspaceId}` - } else if (type === 'comments') { + } else if (AttachmentTypes.COMMENT) { return `/${workspaceId}/${parentTaskId}/comments${entityId ? `/${entityId}` : ''}` } return `/${workspaceId}/templates${entityId ? `/${entityId}` : ''}` @@ -27,7 +27,7 @@ export const uploadAttachmentHandler = async ( token: string, workspaceId: string, entityId: string | null, - type: 'tasks' | 'templates' | 'comments' = 'tasks', + type: AttachmentTypes[keyof AttachmentTypes] = AttachmentTypes.TASK, parentTaskId?: string, ): Promise => { const supabaseActions = new SupabaseActions() @@ -69,10 +69,15 @@ export const deleteEditorAttachmentsHandler = async ( } } -export const getAttachmentPayload = (fileUrl: string, file: File, id: string, entity: 'tasks' | 'comments' = 'tasks') => { +export const getAttachmentPayload = ( + fileUrl: string, + file: File, + id: string, + entity: AttachmentTypes[keyof AttachmentTypes] = AttachmentTypes.TASK, +) => { const filePath = getFilePathFromUrl(fileUrl) - const payload = entity === 'comments' ? { commentId: id } : { taskId: id } + const payload = entity === AttachmentTypes.COMMENT ? { commentId: id } : { taskId: id } return CreateAttachmentRequestSchema.parse({ ...payload, From d848ec4cbeefec5e7699b03a8a0dc0dcf52cd1ba Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 19 Jan 2026 19:23:29 +0545 Subject: [PATCH 04/52] fix(OUT-2914): refactoring + fixed replied/comments attachment creation issues. totally segregated replies/comments to have their own attachment entities --- src/app/api/comment/comment.service.ts | 34 +++++++++-------- src/app/detail/ui/ActivityWrapper.tsx | 8 +++- src/app/detail/ui/Comments.tsx | 3 ++ src/app/detail/ui/NewTaskCard.tsx | 6 ++- src/app/detail/ui/TaskEditor.tsx | 18 +++++---- .../manage-templates/ui/NewTemplateCard.tsx | 7 +++- .../manage-templates/ui/TemplateDetails.tsx | 21 +++++------ src/app/manage-templates/ui/TemplateForm.tsx | 7 +++- src/app/ui/NewTaskForm.tsx | 9 +++-- src/components/cards/CommentCard.tsx | 37 +++++++++---------- src/components/cards/ReplyCard.tsx | 34 ++++++++++++++--- src/components/inputs/CommentInput.tsx | 18 +++++---- src/components/inputs/ReplyInput.tsx | 16 ++++++-- src/utils/attachmentUtils.ts | 2 +- 14 files changed, 141 insertions(+), 79 deletions(-) diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index adf46c538..b2e4aac7a 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -19,7 +19,7 @@ import { TasksService } from '@api/tasks/tasks.service' import { ActivityType, Comment, CommentInitiator } from '@prisma/client' import httpStatus from 'http-status' import { z } from 'zod' -import { AttachmentsService } from '../attachments/attachments.service' +import { AttachmentsService } from '@api/attachments/attachments.service' import { getSignedUrl } from '@/utils/signUrl' export class CommentService extends BaseService { @@ -50,6 +50,23 @@ export class CommentService extends BaseService { }, }) + try { + if (comment.content) { + const newContent = await this.updateCommentIdOfAttachmentsAfterCreation(comment.content, data.taskId, comment.id) + await this.db.comment.update({ + where: { id: comment.id }, + data: { + content: newContent, + updatedAt: comment.createdAt, //dont updated the updatedAt, because it will show (edited) for recently created comments. + }, + }) + console.info('CommentService#createComment | Comment content attachments updated for comment ID:', comment.id) + } + } catch (e: unknown) { + await this.db.comment.delete({ where: { id: comment.id } }) + console.error('CommentService#createComment | Rolling back comment creation', e) + } + if (!comment.parentId) { const activityLogger = new ActivityLogger({ taskId: data.taskId, user: this.user }) await activityLogger.log( @@ -63,21 +80,6 @@ export class CommentService extends BaseService { }), ) await sendCommentCreateNotifications.trigger({ user: this.user, task, comment }) - try { - if (comment.content) { - const newContent = await this.updateCommentIdOfAttachmentsAfterCreation(comment.content, data.taskId, comment.id) - await this.db.comment.update({ - where: { id: comment.id }, - data: { - content: newContent, - }, - }) - console.info('CommentService#createComment | Comment content attachments updated for comment ID:', comment.id) - } - } catch (e: unknown) { - await this.db.comment.delete({ where: { id: comment.id } }) - console.error('CommentService#createComment | Rolling back comment creation', e) - } } else { const tasksService = new TasksService(this.user) await Promise.all([ diff --git a/src/app/detail/ui/ActivityWrapper.tsx b/src/app/detail/ui/ActivityWrapper.tsx index 4d0719286..705c1675a 100644 --- a/src/app/detail/ui/ActivityWrapper.tsx +++ b/src/app/detail/ui/ActivityWrapper.tsx @@ -229,6 +229,7 @@ export const ActivityWrapper = ({ > {item.type === ActivityType.COMMENT_ADDED ? ( @@ -246,7 +247,12 @@ export const ActivityWrapper = ({ ))} - + )} diff --git a/src/app/detail/ui/Comments.tsx b/src/app/detail/ui/Comments.tsx index c9fa4695b..452da3e4a 100644 --- a/src/app/detail/ui/Comments.tsx +++ b/src/app/detail/ui/Comments.tsx @@ -11,6 +11,7 @@ import { VerticalLine } from './styledComponent' import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' interface Prop { + token: string comment: LogResponse createComment: (postCommentPayload: CreateComment) => void deleteComment: (id: string, replyId?: string, softDelete?: boolean) => void @@ -21,6 +22,7 @@ interface Prop { } export const Comments = ({ + token, comment, createComment, deleteComment, @@ -45,6 +47,7 @@ export const Comments = ({ /> uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null) + ? createUploadFn({ + token, + workspaceId: tokenPayload.workspaceId, + }) : undefined const todoWorkflowState = workflowStates.find((el) => el.key === 'todo') || workflowStates[0] diff --git a/src/app/detail/ui/TaskEditor.tsx b/src/app/detail/ui/TaskEditor.tsx index e936a8b8d..076ea1d26 100644 --- a/src/app/detail/ui/TaskEditor.tsx +++ b/src/app/detail/ui/TaskEditor.tsx @@ -19,6 +19,7 @@ import { Box } from '@mui/material' import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { Tapwrite } from 'tapwrite' +import { createUploadFn } from '@/utils/createUploadFn' interface Prop { task_id: string @@ -136,13 +137,16 @@ export const TaskEditor = ({ } const uploadFn = token - ? async (file: File) => { - setActiveUploads((prev) => prev + 1) - const fileUrl = await uploadAttachmentHandler(file, token ?? '', task.workspaceId, task_id) - fileUrl && postAttachment(getAttachmentPayload(fileUrl, file, task_id)) - setActiveUploads((prev) => prev - 1) - return fileUrl - } + ? createUploadFn({ + token, + workspaceId: task.workspaceId, + getEntityId: () => task_id, + onUploadStart: () => setActiveUploads((prev) => prev + 1), + onUploadEnd: () => setActiveUploads((prev) => prev - 1), + onSuccess: (fileUrl, file) => { + postAttachment(getAttachmentPayload(fileUrl, file, task_id)) + }, + }) : undefined return ( diff --git a/src/app/manage-templates/ui/NewTemplateCard.tsx b/src/app/manage-templates/ui/NewTemplateCard.tsx index 7a11b52ea..47cc8bd36 100644 --- a/src/app/manage-templates/ui/NewTemplateCard.tsx +++ b/src/app/manage-templates/ui/NewTemplateCard.tsx @@ -15,6 +15,7 @@ import { CreateTemplateRequest } from '@/types/dto/templates.dto' import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto' import { AttachmentTypes } from '@/types/interfaces' import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' +import { createUploadFn } from '@/utils/createUploadFn' import { Box, Stack, Typography } from '@mui/material' import { useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' @@ -63,7 +64,11 @@ export const NewTemplateCard = ({ } const uploadFn = token && tokenPayload?.workspaceId - ? (file: File) => uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null, AttachmentTypes.TEMPLATE) + ? createUploadFn({ + token, + workspaceId: tokenPayload.workspaceId, + attachmentType: AttachmentTypes.TEMPLATE, + }) : undefined const todoWorkflowState = workflowStates.find((el) => el.key === 'todo') || workflowStates[0] diff --git a/src/app/manage-templates/ui/TemplateDetails.tsx b/src/app/manage-templates/ui/TemplateDetails.tsx index c8d8d85c2..129ec954f 100644 --- a/src/app/manage-templates/ui/TemplateDetails.tsx +++ b/src/app/manage-templates/ui/TemplateDetails.tsx @@ -12,6 +12,7 @@ import store from '@/redux/store' import { CreateTemplateRequest } from '@/types/dto/templates.dto' import { AttachmentTypes, ITemplate } from '@/types/interfaces' import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' +import { createUploadFn } from '@/utils/createUploadFn' import { Box } from '@mui/material' import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' @@ -110,18 +111,14 @@ export default function TemplateDetails({ } const uploadFn = token - ? async (file: File) => { - setActiveUploads((prev) => prev + 1) - const fileUrl = await uploadAttachmentHandler( - file, - token ?? '', - template.workspaceId, - template_id, - AttachmentTypes.TEMPLATE, - ) - setActiveUploads((prev) => prev - 1) - return fileUrl - } + ? createUploadFn({ + token, + workspaceId: template.workspaceId, + getEntityId: () => template_id, + attachmentType: AttachmentTypes.TEMPLATE, + onUploadStart: () => setActiveUploads((prev) => prev + 1), + onUploadEnd: () => setActiveUploads((prev) => prev - 1), + }) : undefined return ( diff --git a/src/app/manage-templates/ui/TemplateForm.tsx b/src/app/manage-templates/ui/TemplateForm.tsx index a7104f4dd..5b55b0114 100644 --- a/src/app/manage-templates/ui/TemplateForm.tsx +++ b/src/app/manage-templates/ui/TemplateForm.tsx @@ -28,6 +28,7 @@ import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' import AttachmentLayout from '@/components/AttachmentLayout' import { StyledModal } from '@/app/detail/ui/styledComponent' +import { createUploadFn } from '@/utils/createUploadFn' export const TemplateForm = ({ handleCreate }: { handleCreate: () => void }) => { const { workflowStates, assignee } = useSelector(selectTaskBoard) @@ -84,7 +85,11 @@ const NewTemplateFormInputs = () => { const uploadFn = token && tokenPayload?.workspaceId - ? async (file: File) => uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null, AttachmentTypes.TEMPLATE) + ? createUploadFn({ + token, + workspaceId: tokenPayload.workspaceId, + attachmentType: AttachmentTypes.TEMPLATE, + }) : undefined const todoWorkflowState = workflowStates.find((el) => el.key === 'todo') || workflowStates[0] diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index 7a704b526..a86a7b091 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -43,6 +43,7 @@ import { } from '@/types/interfaces' import { checkEmptyAssignee, emptyAssignee, getAssigneeName } from '@/utils/assignee' import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' +import { createUploadFn } from '@/utils/createUploadFn' import { getAssigneeTypeCorrected } from '@/utils/getAssigneeTypeCorrected' import { getSelectedUserIds, @@ -562,10 +563,10 @@ const NewTaskFormInputs = ({ isEditorReadonly }: NewTaskFormInputsProps) => { const uploadFn = token && tokenPayload?.workspaceId - ? async (file: File) => { - const fileUrl = await uploadAttachmentHandler(file, token, tokenPayload.workspaceId, null) - return fileUrl - } + ? createUploadFn({ + token, + workspaceId: tokenPayload.workspaceId, + }) : undefined return ( diff --git a/src/components/cards/CommentCard.tsx b/src/components/cards/CommentCard.tsx index d0adf39e4..74aae3e91 100644 --- a/src/components/cards/CommentCard.tsx +++ b/src/components/cards/CommentCard.tsx @@ -43,8 +43,10 @@ import useSWRMutation from 'swr/mutation' import { Tapwrite } from 'tapwrite' import { z } from 'zod' import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' +import { createUploadFn } from '@/utils/createUploadFn' export const CommentCard = ({ + token, comment, createComment, deleteComment, @@ -54,6 +56,7 @@ export const CommentCard = ({ 'data-comment-card': dataCommentCard, //for selection of the element while highlighting the container in notification postAttachment, }: { + token: string comment: LogResponse createComment: (postCommentPayload: CreateComment) => void deleteComment: (id: string, replyId?: string, softDelete?: boolean) => void @@ -75,7 +78,7 @@ export const CommentCard = ({ const { tokenPayload } = useSelector(selectAuthDetails) const canEdit = tokenPayload?.internalUserId == comment?.userId || tokenPayload?.clientId == comment?.userId const canDelete = tokenPayload?.internalUserId == comment?.userId - const { assignee, activeTask, token } = useSelector(selectTaskBoard) + const { assignee, activeTask } = useSelector(selectTaskBoard) const { expandedComments } = useSelector(selectTaskDetails) const [isMenuOpen, setIsMenuOpen] = useState(false) @@ -115,22 +118,17 @@ export const CommentCard = ({ }, [comment.details.id]) //done because tapwrite only takes uploadFn once on mount where commentId will be temp from optimistic update. So we need an actual commentId for uploadFn to work. const uploadFn = token - ? async (file: File) => { - const commentIdFromRef = commentIdRef.current - const commentId = z.string().parse(commentIdFromRef) - if (activeTask) { - const fileUrl = await uploadAttachmentHandler( - file, - token, - activeTask.workspaceId, - commentId, - AttachmentTypes.COMMENT, - task_id, - ) - fileUrl && postAttachment(getAttachmentPayload(fileUrl, file, commentId, AttachmentTypes.COMMENT)) - return fileUrl - } - } + ? createUploadFn({ + token, + workspaceId: activeTask?.workspaceId, + getEntityId: () => z.string().parse(commentIdRef.current), + attachmentType: AttachmentTypes.COMMENT, + parentTaskId: task_id, + onSuccess: (fileUrl, file) => { + const commentId = z.string().parse(commentIdRef.current) + postAttachment(getAttachmentPayload(fileUrl, file, commentId, AttachmentTypes.COMMENT)) + }, + }) : undefined const cancelEdit = () => { @@ -371,13 +369,14 @@ export const CommentCard = ({ return ( ) @@ -387,10 +386,10 @@ export const CommentCard = ({ ((comment as LogResponse).details.replies as LogResponse[]).length > 0) || showReply ? ( diff --git a/src/components/cards/ReplyCard.tsx b/src/components/cards/ReplyCard.tsx index 49d4796fa..a97b1b3f3 100644 --- a/src/components/cards/ReplyCard.tsx +++ b/src/components/cards/ReplyCard.tsx @@ -16,38 +16,42 @@ import { PencilIcon, TrashIcon } from '@/icons' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { UpdateComment } from '@/types/dto/comment.dto' -import { IAssigneeCombined } from '@/types/interfaces' +import { AttachmentTypes, IAssigneeCombined } from '@/types/interfaces' import { getAssigneeName } from '@/utils/assignee' import { getTimeDifference } from '@/utils/getTimeDifference' -import { deleteEditorAttachmentsHandler } from '@/utils/attachmentUtils' +import { deleteEditorAttachmentsHandler, getAttachmentPayload } from '@/utils/attachmentUtils' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { Box, Stack } from '@mui/material' import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { Tapwrite } from 'tapwrite' import { z } from 'zod' +import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' +import { createUploadFn } from '@/utils/createUploadFn' export const ReplyCard = ({ + token, item, - uploadFn, task_id, handleImagePreview, deleteReply, setDeletedReplies, replyInitiator, + postAttachment, }: { + token: string item: ReplyResponse - uploadFn: ((file: File) => Promise) | undefined task_id: string handleImagePreview: (e: React.MouseEvent) => void deleteReply: (id: string, replyId: string) => void setDeletedReplies: Dispatch> replyInitiator: IAssigneeCombined | undefined + postAttachment: (postAttachmentPayload: CreateAttachmentRequest) => void }) => { const [isReadOnly, setIsReadOnly] = useState(true) const [isMenuOpen, setIsMenuOpen] = useState(false) const [isHovered, setIsHovered] = useState(false) - const { token } = useSelector(selectTaskBoard) + const { activeTask } = useSelector(selectTaskBoard) const [showConfirmDeleteModal, setShowConfirmDeleteModal] = useState(false) const { tokenPayload } = useSelector(selectAuthDetails) const windowWidth = useWindowWidth() @@ -57,6 +61,12 @@ export const ReplyCard = ({ const [isFocused, setIsFocused] = useState(false) const editRef = useRef(document.createElement('div')) + const commentIdRef = useRef(item.id) + + useEffect(() => { + commentIdRef.current = item.id + }, [item.id]) + const canEdit = tokenPayload?.internalUserId == item?.initiatorId || tokenPayload?.clientId == item?.initiatorId const isMobile = () => { @@ -113,6 +123,20 @@ export const ReplyCard = ({ } }, [editedContent, isListOrMenuActive, isFocused, isMobile]) + const uploadFn = token + ? createUploadFn({ + token, + workspaceId: activeTask?.workspaceId, + getEntityId: () => z.string().parse(commentIdRef.current), + attachmentType: AttachmentTypes.COMMENT, + parentTaskId: task_id, + onSuccess: (fileUrl, file) => { + const commentId = z.string().parse(commentIdRef.current) + postAttachment(getAttachmentPayload(fileUrl, file, commentId, AttachmentTypes.COMMENT)) + }, + }) + : undefined + return ( <> void task_id: string postAttachment: (postAttachmentPayload: CreateAttachmentRequest) => void } -export const CommentInput = ({ createComment, task_id, postAttachment }: Prop) => { +export const CommentInput = ({ createComment, task_id, postAttachment, token }: Prop) => { const [detail, setDetail] = useState('') const [isListOrMenuActive, setIsListOrMenuActive] = useState(false) const { tokenPayload } = useSelector(selectAuthDetails) - const { assignee, token, activeTask } = useSelector(selectTaskBoard) + const { assignee, activeTask } = useSelector(selectTaskBoard) const currentUserId = tokenPayload?.internalUserId ?? tokenPayload?.clientId const currentUserDetails = assignee.find((el) => el.id === currentUserId) const [isUploading, setIsUploading] = useState(false) @@ -87,13 +89,13 @@ export const CommentInput = ({ createComment, task_id, postAttachment }: Prop) = }, [detail, isListOrMenuActive, isFocused, isMobile]) // Depend on detail to ensure the latest state is captured const uploadFn = token - ? async (file: File) => { - if (activeTask) { - const fileUrl = await uploadAttachmentHandler(file, token ?? '', activeTask.workspaceId, task_id) - return fileUrl - } - } + ? createUploadFn({ + token, + workspaceId: activeTask?.workspaceId, + getEntityId: () => task_id, + }) : undefined + const [isDragging, setIsDragging] = useState(false) const dragCounter = useRef(0) diff --git a/src/components/inputs/ReplyInput.tsx b/src/components/inputs/ReplyInput.tsx index 8aa340209..c1c15674d 100644 --- a/src/components/inputs/ReplyInput.tsx +++ b/src/components/inputs/ReplyInput.tsx @@ -13,26 +13,27 @@ import { Box, Stack } from '@mui/material' import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { Tapwrite } from 'tapwrite' +import { createUploadFn } from '@/utils/createUploadFn' interface ReplyInputProps { + token: string task_id: string comment: any createComment: (postCommentPayload: CreateComment) => void - uploadFn: ((file: File) => Promise) | undefined focusReplyInput: boolean setFocusReplyInput: Dispatch> } export const ReplyInput = ({ + token, task_id, comment, createComment, - uploadFn, focusReplyInput, setFocusReplyInput, }: ReplyInputProps) => { const [detail, setDetail] = useState('') - const { token, assignee } = useSelector(selectTaskBoard) + const { assignee, activeTask } = useSelector(selectTaskBoard) const windowWidth = useWindowWidth() const isMobile = () => { return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || windowWidth < 600 @@ -143,6 +144,15 @@ export const ReplyInput = ({ dragCounter.current = 0 } + const uploadFn = + token && activeTask + ? createUploadFn({ + token, + workspaceId: activeTask.workspaceId, + getEntityId: () => task_id, + }) + : undefined + return ( <> { if (type === AttachmentTypes.TASK) { return entityId ? `/${workspaceId}/${entityId}` : `/${workspaceId}` - } else if (AttachmentTypes.COMMENT) { + } else if (type === AttachmentTypes.COMMENT) { return `/${workspaceId}/${parentTaskId}/comments${entityId ? `/${entityId}` : ''}` } return `/${workspaceId}/templates${entityId ? `/${entityId}` : ''}` From 68a782667d0f412e169cc43ad84ae26298bc55a2 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 19 Jan 2026 19:23:57 +0545 Subject: [PATCH 05/52] fix(OUT-2914): refactoring + fixed replied/comments attachment creation issues. totally segregated replies/comments to have their own attachment entities --- src/utils/createUploadFn.ts | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/utils/createUploadFn.ts diff --git a/src/utils/createUploadFn.ts b/src/utils/createUploadFn.ts new file mode 100644 index 000000000..4116712cb --- /dev/null +++ b/src/utils/createUploadFn.ts @@ -0,0 +1,38 @@ +import { AttachmentTypes } from '@/types/interfaces' +import { uploadAttachmentHandler } from './attachmentUtils' + +interface UploadConfig { + token: string + workspaceId?: string + getEntityId?: () => string | null + attachmentType?: AttachmentTypes + parentTaskId?: string + onUploadStart?: () => void + onUploadEnd?: () => void + onSuccess?: (fileUrl: string, file: File) => void | Promise +} + +export const createUploadFn = (config: UploadConfig) => { + return async (file: File) => { + config.onUploadStart?.() + const entityId = config.getEntityId?.() ?? null //lazily loading the entityId because some of the ids are optimistic id and we want the real ids of comments/replies + try { + const fileUrl = await uploadAttachmentHandler( + file, + config.token, + config?.workspaceId ?? '', + entityId ?? null, + config.attachmentType, + config.parentTaskId, + ) + + if (fileUrl) { + await config.onSuccess?.(fileUrl, file) + } + + return fileUrl + } finally { + config.onUploadEnd?.() + } + } +} From 81b0b88363c12655579bb3e709d00e84bbf978a4 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 20 Jan 2026 12:52:20 +0545 Subject: [PATCH 06/52] fix(OUT-2914): applied requested changes, heavy refactoring --- src/app/api/comment/comment.service.ts | 2 +- src/app/api/tasks/tasksShared.service.ts | 2 +- src/app/detail/[task_id]/[user_type]/page.tsx | 11 +++--- src/app/detail/ui/ActivityWrapper.tsx | 10 +----- src/app/detail/ui/Comments.tsx | 13 +------ src/app/detail/ui/NewTaskCard.tsx | 11 +++--- src/app/detail/ui/TaskEditor.tsx | 22 ++++++------ .../manage-templates/ui/NewTemplateCard.tsx | 13 +++---- .../manage-templates/ui/TemplateDetails.tsx | 18 +++++----- src/app/manage-templates/ui/TemplateForm.tsx | 13 +++---- src/app/ui/NewTaskForm.tsx | 11 +++--- src/components/cards/CommentCard.tsx | 35 +++++++++---------- src/components/cards/ReplyCard.tsx | 34 +++++++++--------- src/components/inputs/CommentInput.tsx | 15 ++++---- src/components/inputs/ReplyInput.tsx | 13 +++---- src/hoc/PostAttachmentProvider.tsx | 30 ++++++++++++++++ src/utils/createUploadFn.ts | 5 ++- 17 files changed, 122 insertions(+), 136 deletions(-) create mode 100644 src/hoc/PostAttachmentProvider.tsx diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index b2e4aac7a..64426999d 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -367,5 +367,5 @@ export class CommentService extends BaseService { // }, // }) //todo: add support for commentId in scrapMedias. return htmlString - } + } //todo: make this resuable since this is highly similar to what we are doing on tasks. } diff --git a/src/app/api/tasks/tasksShared.service.ts b/src/app/api/tasks/tasksShared.service.ts index d5863afaf..5b07daac6 100644 --- a/src/app/api/tasks/tasksShared.service.ts +++ b/src/app/api/tasks/tasksShared.service.ts @@ -427,8 +427,8 @@ export abstract class TasksSharedService extends BaseService { } await Promise.all(copyAttachmentPromises) - const attachmentService = new AttachmentsService(this.user) if (createAttachmentPayloads.length) { + const attachmentService = new AttachmentsService(this.user) await attachmentService.createMultipleAttachments(createAttachmentPayloads) } diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index b0c9c2cdd..aff41864b 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -31,6 +31,7 @@ import { HeaderBreadcrumbs } from '@/components/layouts/HeaderBreadcrumbs' import { SilentError } from '@/components/templates/SilentError' import { apiUrl } from '@/config' import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin' +import { AttachmentProvider } from '@/hoc/PostAttachmentProvider' import { RealTime } from '@/hoc/RealTime' import { RealTimeTemplates } from '@/hoc/RealtimeTemplates' import { WorkspaceResponse } from '@/types/common' @@ -213,16 +214,14 @@ export default async function TaskDetailPage(props: { canCreateSubtasks={params.user_type === UserType.INTERNAL_USER || !!getPreviewMode(tokenPayload)} /> )} - - { 'use server' await postAttachment(token, postAttachmentPayload) }} - /> + > + + void }) => { const { activeTask, assignee } = useSelector(selectTaskBoard) const { expandedComments } = useSelector(selectTaskDetails) @@ -238,7 +236,6 @@ export const ActivityWrapper = ({ task_id={task_id} stableId={z.string().parse(item.details.id) ?? item.id} optimisticUpdates={optimisticUpdates} - postAttachment={postAttachment} /> ) : Object.keys(item).length === 0 ? null : ( @@ -247,12 +244,7 @@ export const ActivityWrapper = ({ ))} - + )} diff --git a/src/app/detail/ui/Comments.tsx b/src/app/detail/ui/Comments.tsx index 452da3e4a..57197ac94 100644 --- a/src/app/detail/ui/Comments.tsx +++ b/src/app/detail/ui/Comments.tsx @@ -18,19 +18,9 @@ interface Prop { task_id: string stableId: string optimisticUpdates: OptimisticUpdate[] - postAttachment: (postAttachmentPayload: CreateAttachmentRequest) => void } -export const Comments = ({ - token, - comment, - createComment, - deleteComment, - task_id, - stableId, - optimisticUpdates, - postAttachment, -}: Prop) => { +export const Comments = ({ token, comment, createComment, deleteComment, task_id, stableId, optimisticUpdates }: Prop) => { const { assignee } = useSelector(selectTaskBoard) const commentInitiator = assignee.find((assignee) => assignee.id == comment.userId) return ( @@ -55,7 +45,6 @@ export const Comments = ({ task_id={task_id} optimisticUpdates={optimisticUpdates} commentInitiator={commentInitiator} - postAttachment={postAttachment} /> diff --git a/src/app/detail/ui/NewTaskCard.tsx b/src/app/detail/ui/NewTaskCard.tsx index d7f6e8e3e..c5105b79e 100644 --- a/src/app/detail/ui/NewTaskCard.tsx +++ b/src/app/detail/ui/NewTaskCard.tsx @@ -104,13 +104,10 @@ export const NewTaskCard = ({ })) } - const uploadFn = - token && tokenPayload?.workspaceId - ? createUploadFn({ - token, - workspaceId: tokenPayload.workspaceId, - }) - : undefined + const uploadFn = createUploadFn({ + token, + workspaceId: tokenPayload?.workspaceId, + }) const todoWorkflowState = workflowStates.find((el) => el.key === 'todo') || workflowStates[0] diff --git a/src/app/detail/ui/TaskEditor.tsx b/src/app/detail/ui/TaskEditor.tsx index 076ea1d26..e264e5a07 100644 --- a/src/app/detail/ui/TaskEditor.tsx +++ b/src/app/detail/ui/TaskEditor.tsx @@ -136,18 +136,16 @@ export const TaskEditor = ({ debouncedResetTypingFlag() } - const uploadFn = token - ? createUploadFn({ - token, - workspaceId: task.workspaceId, - getEntityId: () => task_id, - onUploadStart: () => setActiveUploads((prev) => prev + 1), - onUploadEnd: () => setActiveUploads((prev) => prev - 1), - onSuccess: (fileUrl, file) => { - postAttachment(getAttachmentPayload(fileUrl, file, task_id)) - }, - }) - : undefined + const uploadFn = createUploadFn({ + token, + workspaceId: task.workspaceId, + getEntityId: () => task_id, + onUploadStart: () => setActiveUploads((prev) => prev + 1), + onUploadEnd: () => setActiveUploads((prev) => prev - 1), + onSuccess: (fileUrl, file) => { + postAttachment(getAttachmentPayload(fileUrl, file, task_id)) + }, + }) return ( <> diff --git a/src/app/manage-templates/ui/NewTemplateCard.tsx b/src/app/manage-templates/ui/NewTemplateCard.tsx index 47cc8bd36..919afd292 100644 --- a/src/app/manage-templates/ui/NewTemplateCard.tsx +++ b/src/app/manage-templates/ui/NewTemplateCard.tsx @@ -62,14 +62,11 @@ export const NewTemplateCard = ({ [field]: value, })) } - const uploadFn = - token && tokenPayload?.workspaceId - ? createUploadFn({ - token, - workspaceId: tokenPayload.workspaceId, - attachmentType: AttachmentTypes.TEMPLATE, - }) - : undefined + const uploadFn = createUploadFn({ + token, + workspaceId: tokenPayload?.workspaceId, + attachmentType: AttachmentTypes.TEMPLATE, + }) const todoWorkflowState = workflowStates.find((el) => el.key === 'todo') || workflowStates[0] diff --git a/src/app/manage-templates/ui/TemplateDetails.tsx b/src/app/manage-templates/ui/TemplateDetails.tsx index 129ec954f..b9d6dfcba 100644 --- a/src/app/manage-templates/ui/TemplateDetails.tsx +++ b/src/app/manage-templates/ui/TemplateDetails.tsx @@ -110,16 +110,14 @@ export default function TemplateDetails({ debouncedResetTypingFlag() } - const uploadFn = token - ? createUploadFn({ - token, - workspaceId: template.workspaceId, - getEntityId: () => template_id, - attachmentType: AttachmentTypes.TEMPLATE, - onUploadStart: () => setActiveUploads((prev) => prev + 1), - onUploadEnd: () => setActiveUploads((prev) => prev - 1), - }) - : undefined + const uploadFn = createUploadFn({ + token, + workspaceId: template.workspaceId, + getEntityId: () => template_id, + attachmentType: AttachmentTypes.TEMPLATE, + onUploadStart: () => setActiveUploads((prev) => prev + 1), + onUploadEnd: () => setActiveUploads((prev) => prev - 1), + }) return ( <> diff --git a/src/app/manage-templates/ui/TemplateForm.tsx b/src/app/manage-templates/ui/TemplateForm.tsx index 5b55b0114..b5ff8d4f5 100644 --- a/src/app/manage-templates/ui/TemplateForm.tsx +++ b/src/app/manage-templates/ui/TemplateForm.tsx @@ -83,14 +83,11 @@ const NewTemplateFormInputs = () => { const { workflowStates, token } = useSelector(selectTaskBoard) const { tokenPayload } = useSelector(selectAuthDetails) - const uploadFn = - token && tokenPayload?.workspaceId - ? createUploadFn({ - token, - workspaceId: tokenPayload.workspaceId, - attachmentType: AttachmentTypes.TEMPLATE, - }) - : undefined + const uploadFn = createUploadFn({ + token, + workspaceId: tokenPayload?.workspaceId, + attachmentType: AttachmentTypes.TEMPLATE, + }) const todoWorkflowState = workflowStates.find((el) => el.key === 'todo') || workflowStates[0] const defaultWorkflowState = activeWorkflowStateId diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index a86a7b091..75ee76c9c 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -561,13 +561,10 @@ const NewTaskFormInputs = ({ isEditorReadonly }: NewTaskFormInputsProps) => { store.dispatch(setCreateTaskFields({ targetField: 'description', value: content })) } - const uploadFn = - token && tokenPayload?.workspaceId - ? createUploadFn({ - token, - workspaceId: tokenPayload.workspaceId, - }) - : undefined + const uploadFn = createUploadFn({ + token, + workspaceId: tokenPayload?.workspaceId, + }) return ( <> diff --git a/src/components/cards/CommentCard.tsx b/src/components/cards/CommentCard.tsx index 74aae3e91..504f4af49 100644 --- a/src/components/cards/CommentCard.tsx +++ b/src/components/cards/CommentCard.tsx @@ -19,6 +19,7 @@ import { MenuBox } from '@/components/inputs/MenuBox' import { ReplyInput } from '@/components/inputs/ReplyInput' import { ConfirmDeleteUI } from '@/components/layouts/ConfirmDeleteUI' import { MAX_UPLOAD_LIMIT } from '@/constants/attachments' +import { usePostAttachment } from '@/hoc/PostAttachmentProvider' import { useWindowWidth } from '@/hooks/useWindowWidth' import { PencilIcon, ReplyIcon, TrashIcon } from '@/icons' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' @@ -28,9 +29,10 @@ import store from '@/redux/store' import { CommentResponse, CreateComment, UpdateComment } from '@/types/dto/comment.dto' import { AttachmentTypes, IAssigneeCombined } from '@/types/interfaces' import { getAssigneeName } from '@/utils/assignee' +import { deleteEditorAttachmentsHandler, getAttachmentPayload } from '@/utils/attachmentUtils' +import { createUploadFn } from '@/utils/createUploadFn' import { fetcher } from '@/utils/fetcher' import { getTimeDifference } from '@/utils/getTimeDifference' -import { deleteEditorAttachmentsHandler, getAttachmentPayload, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { checkOptimisticStableId, OptimisticUpdate } from '@/utils/optimisticCommentUtils' import { ReplyResponse } from '@api/activity-logs/schemas/CommentAddedSchema' @@ -42,8 +44,6 @@ import { TransitionGroup } from 'react-transition-group' import useSWRMutation from 'swr/mutation' import { Tapwrite } from 'tapwrite' import { z } from 'zod' -import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' -import { createUploadFn } from '@/utils/createUploadFn' export const CommentCard = ({ token, @@ -54,7 +54,6 @@ export const CommentCard = ({ optimisticUpdates, commentInitiator, 'data-comment-card': dataCommentCard, //for selection of the element while highlighting the container in notification - postAttachment, }: { token: string comment: LogResponse @@ -64,7 +63,6 @@ export const CommentCard = ({ optimisticUpdates: OptimisticUpdate[] commentInitiator: IAssigneeCombined | undefined 'data-comment-card'?: string - postAttachment: (postAttachmentPayload: CreateAttachmentRequest) => void }) => { const [showReply, setShowReply] = useState(false) const [isHovered, setIsHovered] = useState(false) @@ -86,6 +84,8 @@ export const CommentCard = ({ const [deletedReplies, setDeletedReplies] = useState([]) + const { postAttachment } = usePostAttachment() + const windowWidth = useWindowWidth() const isMobile = () => { return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || windowWidth < 600 @@ -117,19 +117,17 @@ export const CommentCard = ({ commentIdRef.current = comment.details.id }, [comment.details.id]) //done because tapwrite only takes uploadFn once on mount where commentId will be temp from optimistic update. So we need an actual commentId for uploadFn to work. - const uploadFn = token - ? createUploadFn({ - token, - workspaceId: activeTask?.workspaceId, - getEntityId: () => z.string().parse(commentIdRef.current), - attachmentType: AttachmentTypes.COMMENT, - parentTaskId: task_id, - onSuccess: (fileUrl, file) => { - const commentId = z.string().parse(commentIdRef.current) - postAttachment(getAttachmentPayload(fileUrl, file, commentId, AttachmentTypes.COMMENT)) - }, - }) - : undefined + const uploadFn = createUploadFn({ + token, + workspaceId: activeTask?.workspaceId, + getEntityId: () => z.string().parse(commentIdRef.current), + attachmentType: AttachmentTypes.COMMENT, + parentTaskId: task_id, + onSuccess: (fileUrl, file) => { + const commentId = z.string().parse(commentIdRef.current) + postAttachment(getAttachmentPayload(fileUrl, file, commentId, AttachmentTypes.COMMENT)) + }, + }) const cancelEdit = () => { setIsReadOnly(true) @@ -376,7 +374,6 @@ export const CommentCard = ({ deleteReply={deleteComment} setDeletedReplies={setDeletedReplies} replyInitiator={replyInitiator} - postAttachment={postAttachment} /> ) diff --git a/src/components/cards/ReplyCard.tsx b/src/components/cards/ReplyCard.tsx index a97b1b3f3..7c5049d90 100644 --- a/src/components/cards/ReplyCard.tsx +++ b/src/components/cards/ReplyCard.tsx @@ -11,6 +11,7 @@ import { EditCommentButtons } from '@/components/buttonsGroup/EditCommentButtons import { MenuBox } from '@/components/inputs/MenuBox' import { ConfirmDeleteUI } from '@/components/layouts/ConfirmDeleteUI' import { MAX_UPLOAD_LIMIT } from '@/constants/attachments' +import { usePostAttachment } from '@/hoc/PostAttachmentProvider' import { useWindowWidth } from '@/hooks/useWindowWidth' import { PencilIcon, TrashIcon } from '@/icons' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' @@ -18,16 +19,15 @@ import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { UpdateComment } from '@/types/dto/comment.dto' import { AttachmentTypes, IAssigneeCombined } from '@/types/interfaces' import { getAssigneeName } from '@/utils/assignee' -import { getTimeDifference } from '@/utils/getTimeDifference' import { deleteEditorAttachmentsHandler, getAttachmentPayload } from '@/utils/attachmentUtils' +import { createUploadFn } from '@/utils/createUploadFn' +import { getTimeDifference } from '@/utils/getTimeDifference' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { Box, Stack } from '@mui/material' import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { Tapwrite } from 'tapwrite' import { z } from 'zod' -import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' -import { createUploadFn } from '@/utils/createUploadFn' export const ReplyCard = ({ token, @@ -37,7 +37,6 @@ export const ReplyCard = ({ deleteReply, setDeletedReplies, replyInitiator, - postAttachment, }: { token: string item: ReplyResponse @@ -46,7 +45,6 @@ export const ReplyCard = ({ deleteReply: (id: string, replyId: string) => void setDeletedReplies: Dispatch> replyInitiator: IAssigneeCombined | undefined - postAttachment: (postAttachmentPayload: CreateAttachmentRequest) => void }) => { const [isReadOnly, setIsReadOnly] = useState(true) const [isMenuOpen, setIsMenuOpen] = useState(false) @@ -85,6 +83,8 @@ export const ReplyCard = ({ const canDelete = tokenPayload?.internalUserId == item?.initiatorId + const { postAttachment } = usePostAttachment() + const handleEdit = async () => { if (isTapwriteContentEmpty(editedContent)) { setEditedContent(content) @@ -123,19 +123,17 @@ export const ReplyCard = ({ } }, [editedContent, isListOrMenuActive, isFocused, isMobile]) - const uploadFn = token - ? createUploadFn({ - token, - workspaceId: activeTask?.workspaceId, - getEntityId: () => z.string().parse(commentIdRef.current), - attachmentType: AttachmentTypes.COMMENT, - parentTaskId: task_id, - onSuccess: (fileUrl, file) => { - const commentId = z.string().parse(commentIdRef.current) - postAttachment(getAttachmentPayload(fileUrl, file, commentId, AttachmentTypes.COMMENT)) - }, - }) - : undefined + const uploadFn = createUploadFn({ + token, + workspaceId: activeTask?.workspaceId, + getEntityId: () => z.string().parse(commentIdRef.current), + attachmentType: AttachmentTypes.COMMENT, + parentTaskId: task_id, + onSuccess: (fileUrl, file) => { + const commentId = z.string().parse(commentIdRef.current) + postAttachment(getAttachmentPayload(fileUrl, file, commentId, AttachmentTypes.COMMENT)) + }, + }) return ( <> diff --git a/src/components/inputs/CommentInput.tsx b/src/components/inputs/CommentInput.tsx index 6ab1668ec..739f90b90 100644 --- a/src/components/inputs/CommentInput.tsx +++ b/src/components/inputs/CommentInput.tsx @@ -23,10 +23,9 @@ interface Prop { token: string createComment: (postCommentPayload: CreateComment) => void task_id: string - postAttachment: (postAttachmentPayload: CreateAttachmentRequest) => void } -export const CommentInput = ({ createComment, task_id, postAttachment, token }: Prop) => { +export const CommentInput = ({ createComment, task_id, token }: Prop) => { const [detail, setDetail] = useState('') const [isListOrMenuActive, setIsListOrMenuActive] = useState(false) const { tokenPayload } = useSelector(selectAuthDetails) @@ -88,13 +87,11 @@ export const CommentInput = ({ createComment, task_id, postAttachment, token }: /* eslint-disable-next-line react-hooks/exhaustive-deps */ // }, [detail, isListOrMenuActive, isFocused, isMobile]) // Depend on detail to ensure the latest state is captured - const uploadFn = token - ? createUploadFn({ - token, - workspaceId: activeTask?.workspaceId, - getEntityId: () => task_id, - }) - : undefined + const uploadFn = createUploadFn({ + token, + workspaceId: activeTask?.workspaceId, + getEntityId: () => task_id, + }) const [isDragging, setIsDragging] = useState(false) const dragCounter = useRef(0) diff --git a/src/components/inputs/ReplyInput.tsx b/src/components/inputs/ReplyInput.tsx index c1c15674d..d686c8a31 100644 --- a/src/components/inputs/ReplyInput.tsx +++ b/src/components/inputs/ReplyInput.tsx @@ -144,14 +144,11 @@ export const ReplyInput = ({ dragCounter.current = 0 } - const uploadFn = - token && activeTask - ? createUploadFn({ - token, - workspaceId: activeTask.workspaceId, - getEntityId: () => task_id, - }) - : undefined + const uploadFn = createUploadFn({ + token, + workspaceId: activeTask?.workspaceId, + getEntityId: () => task_id, + }) return ( <> diff --git a/src/hoc/PostAttachmentProvider.tsx b/src/hoc/PostAttachmentProvider.tsx new file mode 100644 index 000000000..3d44d4083 --- /dev/null +++ b/src/hoc/PostAttachmentProvider.tsx @@ -0,0 +1,30 @@ +'use client' + +import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' +import React, { createContext, useContext } from 'react' + +type AttachmentContextType = { + postAttachment: (payload: CreateAttachmentRequest) => Promise +} + +const AttachmentContext = createContext(null) + +export function usePostAttachment() { + const context = useContext(AttachmentContext) + + if (!context) { + throw new Error('useAttachment must be used within ') + } + + return context +} + +export function AttachmentProvider({ + postAttachment, + children, +}: { + postAttachment: AttachmentContextType['postAttachment'] + children: React.ReactNode +}) { + return {children} +} diff --git a/src/utils/createUploadFn.ts b/src/utils/createUploadFn.ts index 4116712cb..43b660f23 100644 --- a/src/utils/createUploadFn.ts +++ b/src/utils/createUploadFn.ts @@ -2,7 +2,7 @@ import { AttachmentTypes } from '@/types/interfaces' import { uploadAttachmentHandler } from './attachmentUtils' interface UploadConfig { - token: string + token?: string workspaceId?: string getEntityId?: () => string | null attachmentType?: AttachmentTypes @@ -16,6 +16,9 @@ export const createUploadFn = (config: UploadConfig) => { return async (file: File) => { config.onUploadStart?.() const entityId = config.getEntityId?.() ?? null //lazily loading the entityId because some of the ids are optimistic id and we want the real ids of comments/replies + if (!config.token) { + return undefined + } try { const fileUrl = await uploadAttachmentHandler( file, From 1c0d11c3f3172f70ed803637461af39d04c94007 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 20 Jan 2026 15:07:45 +0545 Subject: [PATCH 07/52] fix(OUT-2914): added a check for workspace id before uploading an attachment. workspaceId is needed for building folder structure effecting filePaths --- src/utils/createUploadFn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/createUploadFn.ts b/src/utils/createUploadFn.ts index 43b660f23..80679d721 100644 --- a/src/utils/createUploadFn.ts +++ b/src/utils/createUploadFn.ts @@ -16,7 +16,7 @@ export const createUploadFn = (config: UploadConfig) => { return async (file: File) => { config.onUploadStart?.() const entityId = config.getEntityId?.() ?? null //lazily loading the entityId because some of the ids are optimistic id and we want the real ids of comments/replies - if (!config.token) { + if (!config.token || !config.workspaceId) { return undefined } try { From c3752d657e3535e11e0d64cd069a8a66e096af2d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 14 Jan 2026 19:52:14 +0545 Subject: [PATCH 08/52] feat(OUT-2917): public api to list comments of a task - [x] public api route that gets list of comments for a task - [x] taskId required validation - [x] accept params: taskId, parentCommentId, createdBy - [x] include attachment in reponse with presigned download url --- src/app/api/comment/comment.service.ts | 40 ++++++++++- .../public/comment-public.controller.ts | 48 +++++++++++++ .../api/comment/public/comment-public.dto.ts | 29 ++++++++ .../public/comment-public.serializer.ts | 70 +++++++++++++++++++ src/app/api/comment/public/route.ts | 4 ++ src/types/dto/comment.dto.ts | 11 +++ 6 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 src/app/api/comment/public/comment-public.controller.ts create mode 100644 src/app/api/comment/public/comment-public.dto.ts create mode 100644 src/app/api/comment/public/comment-public.serializer.ts create mode 100644 src/app/api/comment/public/route.ts diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 64426999d..8a8a54722 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -2,7 +2,7 @@ import { sendCommentCreateNotifications } from '@/jobs/notifications' import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' import { InitiatedEntity } from '@/types/common' import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' -import { CreateComment, UpdateComment } from '@/types/dto/comment.dto' +import { CommentsPublicFilterType, CommentWithAttachments, CreateComment, UpdateComment } from '@/types/dto/comment.dto' import { getArrayDifference, getArrayIntersection } from '@/utils/array' import { getFileNameFromPath } from '@/utils/attachmentUtils' import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' @@ -16,7 +16,7 @@ import { PoliciesService } from '@api/core/services/policies.service' import { Resource } from '@api/core/types/api' import { UserAction } from '@api/core/types/user' import { TasksService } from '@api/tasks/tasks.service' -import { ActivityType, Comment, CommentInitiator } from '@prisma/client' +import { ActivityType, Comment, CommentInitiator, Prisma } from '@prisma/client' import httpStatus from 'http-status' import { z } from 'zod' import { AttachmentsService } from '@api/attachments/attachments.service' @@ -368,4 +368,40 @@ export class CommentService extends BaseService { // }) //todo: add support for commentId in scrapMedias. return htmlString } //todo: make this resuable since this is highly similar to what we are doing on tasks. + + async getAllComments(queryFilters: CommentsPublicFilterType): Promise { + const { parentId, taskId, limit, lastIdCursor, initiatorId } = queryFilters + const where = { + parentId, + taskId, + initiatorId, + workspaceId: this.user.workspaceId, + } + + const pagination: Prisma.CommentFindManyArgs = { + take: limit, + cursor: lastIdCursor ? { id: lastIdCursor } : undefined, + skip: lastIdCursor ? 1 : undefined, + } + + return await this.db.comment.findMany({ + where, + ...pagination, + include: { attachments: true }, + orderBy: { createdAt: 'desc' }, + }) + } + + async hasMoreCommentsAfterCursor( + id: string, + publicFilters: Partial[0]>, + ): Promise { + const newComment = await this.db.comment.findFirst({ + where: { ...publicFilters, workspaceId: this.user.workspaceId }, + cursor: { id }, + skip: 1, + orderBy: { createdAt: 'desc' }, + }) + return !!newComment + } } diff --git a/src/app/api/comment/public/comment-public.controller.ts b/src/app/api/comment/public/comment-public.controller.ts new file mode 100644 index 000000000..c47bb5fee --- /dev/null +++ b/src/app/api/comment/public/comment-public.controller.ts @@ -0,0 +1,48 @@ +import { CommentService } from '@/app/api/comment/comment.service' +import authenticate from '@/app/api/core/utils/authenticate' +import { defaultLimit } from '@/constants/public-api' +import { getSearchParams } from '@/utils/request' +import { NextRequest, NextResponse } from 'next/server' +import { decode, encode } from 'js-base64' +import { PublicCommentSerializer } from '@/app/api/comment/public/comment-public.serializer' +import APIError from '@/app/api/core/exceptions/api' +import httpStatus from 'http-status' +import { CommentsPublicFilterType } from '@/types/dto/comment.dto' + +export const getAllCommentsPublic = async (req: NextRequest) => { + const user = await authenticate(req) + + const { parentCommentId, taskId, createdBy, limit, nextToken } = getSearchParams(req.nextUrl.searchParams, [ + 'parentCommentId', + 'taskId', + 'createdBy', + 'limit', + 'nextToken', + ]) + + if (!taskId) throw new APIError(httpStatus.BAD_REQUEST, 'taskId is required') + + const publicFilters: CommentsPublicFilterType = { + taskId, + parentId: (parentCommentId === 'null' ? null : parentCommentId) || undefined, + initiatorId: createdBy || undefined, + } + + const commentService = new CommentService(user) + const comments = await commentService.getAllComments({ + limit: limit ? +limit : defaultLimit, + lastIdCursor: nextToken ? decode(nextToken) : undefined, + ...publicFilters, + }) + + const lastCommentId = comments[comments.length - 1]?.id + const hasMoreComments = lastCommentId + ? await commentService.hasMoreCommentsAfterCursor(lastCommentId, publicFilters) + : false + const base64NextToken = hasMoreComments ? encode(lastCommentId) : undefined + + return NextResponse.json({ + data: await PublicCommentSerializer.serializeMany(comments), + nextToken: base64NextToken, + }) +} diff --git a/src/app/api/comment/public/comment-public.dto.ts b/src/app/api/comment/public/comment-public.dto.ts new file mode 100644 index 000000000..2bf52547a --- /dev/null +++ b/src/app/api/comment/public/comment-public.dto.ts @@ -0,0 +1,29 @@ +import { RFC3339DateSchema } from '@/types/common' +import { AssigneeType } from '@prisma/client' +import z from 'zod' + +export const PublicAttachmentDtoSchema = z.object({ + id: z.string().uuid(), + fileName: z.string(), + fileSize: z.number(), + mimeType: z.string(), + downloadUrl: z.string().url(), + uploadedBy: z.string().uuid(), + uploadedByUserType: z.nativeEnum(AssigneeType).nullable(), + uploadedDate: RFC3339DateSchema, +}) +export type PublicAttachmentDto = z.infer + +export const PublicCommentDtoSchema = z.object({ + id: z.string().uuid(), + object: z.literal('taskComment'), + taskId: z.string().uuid(), + parentCommentId: z.string().uuid().nullable(), + content: z.string(), + createdBy: z.string().uuid(), + createdByUserType: z.nativeEnum(AssigneeType).nullable(), + createdDate: RFC3339DateSchema, + updatedDate: RFC3339DateSchema, + attachments: z.array(PublicAttachmentDtoSchema).nullable(), +}) +export type PublicCommentDto = z.infer diff --git a/src/app/api/comment/public/comment-public.serializer.ts b/src/app/api/comment/public/comment-public.serializer.ts new file mode 100644 index 000000000..019d34516 --- /dev/null +++ b/src/app/api/comment/public/comment-public.serializer.ts @@ -0,0 +1,70 @@ +import { PublicAttachmentDto, PublicCommentDto, PublicCommentDtoSchema } from '@/app/api/comment/public/comment-public.dto' +import { RFC3339DateSchema } from '@/types/common' +import { CommentWithAttachments } from '@/types/dto/comment.dto' +import { toRFC3339 } from '@/utils/dateHelper' +import { getSignedUrl } from '@/utils/signUrl' +import { Attachment, CommentInitiator } from '@prisma/client' +import { z } from 'zod' + +export class PublicCommentSerializer { + static async serializeUnsafe(comment: CommentWithAttachments): Promise { + return { + id: comment.id, + object: 'taskComment', + parentCommentId: comment.parentId, + taskId: comment.taskId, + content: comment.content, + createdBy: comment.initiatorId, + createdByUserType: comment.initiatorType, + createdDate: RFC3339DateSchema.parse(toRFC3339(comment.createdAt)), + updatedDate: RFC3339DateSchema.parse(toRFC3339(comment.updatedAt)), + attachments: await PublicCommentSerializer.serializeAttachments({ + attachments: comment.attachments, + uploadedByUserType: comment.initiatorType, + uploadedBy: comment.initiatorId, + }), + } + } + + /** + * + * @param attachments array of Attachment + * @param uploadedBy id of the one who commented + * @param uploadedByUserType usertype of the one who commented + * @returns Array of PublicAttachmentDto + */ + static async serializeAttachments({ + attachments, + uploadedByUserType, + uploadedBy, + }: { + attachments: Attachment[] + uploadedByUserType: CommentInitiator | null + uploadedBy: string + }): Promise { + const promises = attachments.map(async (attachment) => ({ + id: attachment.id, + fileName: attachment.fileName, + fileSize: attachment.fileSize, + mimeType: attachment.fileType, + downloadUrl: z + .string({ + message: `Invalid downloadUrl for attachment with id ${attachment.id}`, + }) + .parse(await getSignedUrl(attachment.filePath)), + uploadedBy, + uploadedByUserType, + uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), + })) + return await Promise.all(promises) + } + + static async serialize(comment: CommentWithAttachments): Promise { + return PublicCommentDtoSchema.parse(await PublicCommentSerializer.serializeUnsafe(comment)) + } + + static async serializeMany(comments: CommentWithAttachments[]): Promise { + const serializedComments = await Promise.all(comments.map(async (comment) => PublicCommentSerializer.serialize(comment))) + return z.array(PublicCommentDtoSchema).parse(serializedComments) + } +} diff --git a/src/app/api/comment/public/route.ts b/src/app/api/comment/public/route.ts new file mode 100644 index 000000000..5b52b0b42 --- /dev/null +++ b/src/app/api/comment/public/route.ts @@ -0,0 +1,4 @@ +import { getAllCommentsPublic } from '@/app/api/comment/public/comment-public.controller' +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' + +export const GET = withErrorHandler(getAllCommentsPublic) diff --git a/src/types/dto/comment.dto.ts b/src/types/dto/comment.dto.ts index da92c41dd..326a74a41 100644 --- a/src/types/dto/comment.dto.ts +++ b/src/types/dto/comment.dto.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { AttachmentResponseSchema } from './attachments.dto' +import { Attachment, Comment } from '@prisma/client' export const CreateCommentSchema = z.object({ content: z.string(), @@ -37,3 +38,13 @@ export const CommentResponseSchema: z.ZodType = z.lazy(() => ) export type CommentResponse = z.infer + +export type CommentWithAttachments = Comment & { attachments: Attachment[] } + +export type CommentsPublicFilterType = { + taskId: string + parentId?: string + initiatorId?: string + limit?: number + lastIdCursor?: string +} From 9a6c08bba9be954cf95f74413d8524b587e409e9 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 14 Jan 2026 20:24:20 +0545 Subject: [PATCH 09/52] refactor(OUT-2917): expose comments list route as sub-resource on tasks --- .../api/comment/public/comment-public.controller.ts | 12 ++++-------- src/app/api/comment/public/route.ts | 4 ---- src/app/api/tasks/public/[id]/comments/route.ts | 4 ++++ 3 files changed, 8 insertions(+), 12 deletions(-) delete mode 100644 src/app/api/comment/public/route.ts create mode 100644 src/app/api/tasks/public/[id]/comments/route.ts diff --git a/src/app/api/comment/public/comment-public.controller.ts b/src/app/api/comment/public/comment-public.controller.ts index c47bb5fee..b04b12ac3 100644 --- a/src/app/api/comment/public/comment-public.controller.ts +++ b/src/app/api/comment/public/comment-public.controller.ts @@ -5,25 +5,21 @@ import { getSearchParams } from '@/utils/request' import { NextRequest, NextResponse } from 'next/server' import { decode, encode } from 'js-base64' import { PublicCommentSerializer } from '@/app/api/comment/public/comment-public.serializer' -import APIError from '@/app/api/core/exceptions/api' -import httpStatus from 'http-status' import { CommentsPublicFilterType } from '@/types/dto/comment.dto' +import { IdParams } from '@/app/api/core/types/api' -export const getAllCommentsPublic = async (req: NextRequest) => { +export const getAllCommentsPublicForTask = async (req: NextRequest, { params: { id } }: IdParams) => { const user = await authenticate(req) - const { parentCommentId, taskId, createdBy, limit, nextToken } = getSearchParams(req.nextUrl.searchParams, [ + const { parentCommentId, createdBy, limit, nextToken } = getSearchParams(req.nextUrl.searchParams, [ 'parentCommentId', - 'taskId', 'createdBy', 'limit', 'nextToken', ]) - if (!taskId) throw new APIError(httpStatus.BAD_REQUEST, 'taskId is required') - const publicFilters: CommentsPublicFilterType = { - taskId, + taskId: id, parentId: (parentCommentId === 'null' ? null : parentCommentId) || undefined, initiatorId: createdBy || undefined, } diff --git a/src/app/api/comment/public/route.ts b/src/app/api/comment/public/route.ts deleted file mode 100644 index 5b52b0b42..000000000 --- a/src/app/api/comment/public/route.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { getAllCommentsPublic } from '@/app/api/comment/public/comment-public.controller' -import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' - -export const GET = withErrorHandler(getAllCommentsPublic) diff --git a/src/app/api/tasks/public/[id]/comments/route.ts b/src/app/api/tasks/public/[id]/comments/route.ts new file mode 100644 index 000000000..56fa05626 --- /dev/null +++ b/src/app/api/tasks/public/[id]/comments/route.ts @@ -0,0 +1,4 @@ +import { getAllCommentsPublicForTask } from '@/app/api/comment/public/comment-public.controller' +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' + +export const GET = withErrorHandler(getAllCommentsPublicForTask) From 820cc3bd52605f827cade059bb60145127a43708 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 12:56:54 +0545 Subject: [PATCH 10/52] fix(OUT-2917): await path params --- src/app/api/comment/public/comment-public.controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/api/comment/public/comment-public.controller.ts b/src/app/api/comment/public/comment-public.controller.ts index b04b12ac3..6a38405c0 100644 --- a/src/app/api/comment/public/comment-public.controller.ts +++ b/src/app/api/comment/public/comment-public.controller.ts @@ -8,7 +8,8 @@ import { PublicCommentSerializer } from '@/app/api/comment/public/comment-public import { CommentsPublicFilterType } from '@/types/dto/comment.dto' import { IdParams } from '@/app/api/core/types/api' -export const getAllCommentsPublicForTask = async (req: NextRequest, { params: { id } }: IdParams) => { +export const getAllCommentsPublicForTask = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const { parentCommentId, createdBy, limit, nextToken } = getSearchParams(req.nextUrl.searchParams, [ From 96b8be2e3c18366c2dd270470c8f8bc47d04d82e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 16:20:10 +0545 Subject: [PATCH 11/52] refactor(OUT-2917): implemented proper typing, validation --- prisma/schema/comment.prisma | 1 + src/app/api/comment/comment.service.ts | 12 +++-------- .../public/comment-public.controller.ts | 5 +++-- src/app/api/tasks/public/public.service.ts | 7 ++----- src/utils/pagination.ts | 21 +++++++++++++++++++ 5 files changed, 30 insertions(+), 16 deletions(-) create mode 100644 src/utils/pagination.ts diff --git a/prisma/schema/comment.prisma b/prisma/schema/comment.prisma index d2cdba4df..430c3ac68 100644 --- a/prisma/schema/comment.prisma +++ b/prisma/schema/comment.prisma @@ -22,4 +22,5 @@ model Comment { deletedAt DateTime? @db.Timestamptz() @@map("Comments") + @@index([taskId, workspaceId, createdAt(sort: Desc)], name: "IX_Comments_taskId_workspaceId_createdAt") } diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 8a8a54722..05d46fd8d 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -7,6 +7,7 @@ import { getArrayDifference, getArrayIntersection } from '@/utils/array' import { getFileNameFromPath } from '@/utils/attachmentUtils' import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' import { SupabaseActions } from '@/utils/SupabaseActions' +import { getBasicPaginationAttributes } from '@/utils/pagination' import { CommentAddedSchema } from '@api/activity-logs/schemas/CommentAddedSchema' import { ActivityLogger } from '@api/activity-logs/services/activity-logger.service' import { CommentRepository } from '@api/comment/comment.repository' @@ -378,11 +379,7 @@ export class CommentService extends BaseService { workspaceId: this.user.workspaceId, } - const pagination: Prisma.CommentFindManyArgs = { - take: limit, - cursor: lastIdCursor ? { id: lastIdCursor } : undefined, - skip: lastIdCursor ? 1 : undefined, - } + const pagination = getBasicPaginationAttributes(limit, lastIdCursor) return await this.db.comment.findMany({ where, @@ -392,10 +389,7 @@ export class CommentService extends BaseService { }) } - async hasMoreCommentsAfterCursor( - id: string, - publicFilters: Partial[0]>, - ): Promise { + async hasMoreCommentsAfterCursor(id: string, publicFilters: Partial): Promise { const newComment = await this.db.comment.findFirst({ where: { ...publicFilters, workspaceId: this.user.workspaceId }, cursor: { id }, diff --git a/src/app/api/comment/public/comment-public.controller.ts b/src/app/api/comment/public/comment-public.controller.ts index 6a38405c0..9a9f4c521 100644 --- a/src/app/api/comment/public/comment-public.controller.ts +++ b/src/app/api/comment/public/comment-public.controller.ts @@ -7,6 +7,7 @@ import { decode, encode } from 'js-base64' import { PublicCommentSerializer } from '@/app/api/comment/public/comment-public.serializer' import { CommentsPublicFilterType } from '@/types/dto/comment.dto' import { IdParams } from '@/app/api/core/types/api' +import { getPaginationLimit } from '@/utils/pagination' export const getAllCommentsPublicForTask = async (req: NextRequest, { params }: IdParams) => { const { id } = await params @@ -21,13 +22,13 @@ export const getAllCommentsPublicForTask = async (req: NextRequest, { params }: const publicFilters: CommentsPublicFilterType = { taskId: id, - parentId: (parentCommentId === 'null' ? null : parentCommentId) || undefined, + parentId: parentCommentId || undefined, initiatorId: createdBy || undefined, } const commentService = new CommentService(user) const comments = await commentService.getAllComments({ - limit: limit ? +limit : defaultLimit, + limit: getPaginationLimit(limit), lastIdCursor: nextToken ? decode(nextToken) : undefined, ...publicFilters, }) diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index f4f3745f7..d1fef0c3e 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -24,6 +24,7 @@ import { SubtaskService } from '@api/tasks/subtasks.service' import { TasksActivityLogger } from '@api/tasks/tasks.logger' import { TemplatesService } from '@api/tasks/templates/templates.service' import { PublicTaskSerializer } from '@api/tasks/public/public.serializer' +import { getBasicPaginationAttributes } from '@/utils/pagination' export class PublicTasksService extends TasksSharedService { async getAllTasks(queryFilters: { @@ -80,11 +81,7 @@ export class PublicTasksService extends TasksSharedService { } const orderBy: Prisma.TaskOrderByWithRelationInput[] = [{ createdAt: 'desc' }] - const pagination: Prisma.TaskFindManyArgs = { - take: queryFilters.limit, - cursor: queryFilters.lastIdCursor ? { id: queryFilters.lastIdCursor } : undefined, - skip: queryFilters.lastIdCursor ? 1 : undefined, - } + const pagination = getBasicPaginationAttributes(queryFilters.limit, queryFilters.lastIdCursor) const tasks = await this.db.task.findMany({ where, diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts new file mode 100644 index 000000000..a61f7c54b --- /dev/null +++ b/src/utils/pagination.ts @@ -0,0 +1,21 @@ +import { defaultLimit } from '@/constants/public-api' +import z from 'zod' + +type PrismaPaginationArgs = { + take?: number + skip?: number + cursor?: { id: string } +} + +export function getBasicPaginationAttributes(limit?: number, lastIdCursor?: string): PrismaPaginationArgs { + return { + take: limit, + cursor: lastIdCursor ? { id: lastIdCursor } : undefined, + skip: lastIdCursor ? 1 : undefined, + } +} + +export function getPaginationLimit(limit?: number | string | null) { + const safeLimit = z.coerce.number().safeParse(limit) + return !safeLimit.success || !safeLimit.data ? defaultLimit : safeLimit.data +} From 1626f0ea4d469e395e2659552dc5d5a1465f0c87 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 16:20:53 +0545 Subject: [PATCH 12/52] perf(OUT-2917): index comment table and get multiple signed urls from supabase storage at once --- .../migration.sql | 2 + .../public/comment-public.serializer.ts | 41 ++++++++++++------- src/utils/signUrl.ts | 9 ++++ 3 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 prisma/migrations/20260115090155_add_created_at_task_id_worspace_id_index_on_comment/migration.sql diff --git a/prisma/migrations/20260115090155_add_created_at_task_id_worspace_id_index_on_comment/migration.sql b/prisma/migrations/20260115090155_add_created_at_task_id_worspace_id_index_on_comment/migration.sql new file mode 100644 index 000000000..7e6ad8d26 --- /dev/null +++ b/prisma/migrations/20260115090155_add_created_at_task_id_worspace_id_index_on_comment/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "IX_Comments_taskId_workspaceId_createdAt" ON "Comments"("taskId", "workspaceId", "createdAt" DESC); diff --git a/src/app/api/comment/public/comment-public.serializer.ts b/src/app/api/comment/public/comment-public.serializer.ts index 019d34516..daf1ba0d2 100644 --- a/src/app/api/comment/public/comment-public.serializer.ts +++ b/src/app/api/comment/public/comment-public.serializer.ts @@ -2,7 +2,7 @@ import { PublicAttachmentDto, PublicCommentDto, PublicCommentDtoSchema } from '@ import { RFC3339DateSchema } from '@/types/common' import { CommentWithAttachments } from '@/types/dto/comment.dto' import { toRFC3339 } from '@/utils/dateHelper' -import { getSignedUrl } from '@/utils/signUrl' +import { createSignedUrls } from '@/utils/signUrl' import { Attachment, CommentInitiator } from '@prisma/client' import { z } from 'zod' @@ -42,20 +42,25 @@ export class PublicCommentSerializer { uploadedByUserType: CommentInitiator | null uploadedBy: string }): Promise { - const promises = attachments.map(async (attachment) => ({ - id: attachment.id, - fileName: attachment.fileName, - fileSize: attachment.fileSize, - mimeType: attachment.fileType, - downloadUrl: z - .string({ - message: `Invalid downloadUrl for attachment with id ${attachment.id}`, - }) - .parse(await getSignedUrl(attachment.filePath)), - uploadedBy, - uploadedByUserType, - uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), - })) + const attachmentPaths = attachments.map((attachment) => attachment.filePath) + const signedUrls = await PublicCommentSerializer.getFormattedSignedUrls(attachmentPaths) + + const promises = attachments.map(async (attachment) => { + const url = signedUrls.find((item) => item.path === attachment.filePath)?.url + return { + id: attachment.id, + fileName: attachment.fileName, + fileSize: attachment.fileSize, + mimeType: attachment.fileType, + downloadUrl: z + .string() + .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) + .parse(url), + uploadedBy, + uploadedByUserType, + uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), + } + }) return await Promise.all(promises) } @@ -67,4 +72,10 @@ export class PublicCommentSerializer { const serializedComments = await Promise.all(comments.map(async (comment) => PublicCommentSerializer.serialize(comment))) return z.array(PublicCommentDtoSchema).parse(serializedComments) } + + static async getFormattedSignedUrls(attachmentPaths: string[]) { + if (!attachmentPaths.length) return [] + const signedUrls = await createSignedUrls(attachmentPaths) + return signedUrls.map((item) => ({ path: item.path, url: item.signedUrl })) + } } diff --git a/src/utils/signUrl.ts b/src/utils/signUrl.ts index ef5a20499..a66e85dd5 100644 --- a/src/utils/signUrl.ts +++ b/src/utils/signUrl.ts @@ -11,6 +11,15 @@ export const getSignedUrl = async (filePath: string) => { return url } // used to replace urls for images in task body +export const createSignedUrls = async (filePaths: string[]) => { + const supabase = new SupabaseService() + const { data, error } = await supabase.supabase.storage.from(supabaseBucket).createSignedUrls(filePaths, signedUrlTtl) + if (error) { + throw new Error(error.message) + } + return data +} + export const getFileNameFromSignedUrl = (url: string) => { // Aggressive regex that selects string from last '/'' to url param (starting with ?) const regex = /.*\/([^\/\?]+)(?:\?.*)?$/ From 4927e5c42027d6739886632eb65f86ba675ad7e1 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 17:09:30 +0545 Subject: [PATCH 13/52] fix(OUT-2917): sequentially map the attachments --- src/app/api/comment/public/comment-public.serializer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/api/comment/public/comment-public.serializer.ts b/src/app/api/comment/public/comment-public.serializer.ts index daf1ba0d2..e5a3295fd 100644 --- a/src/app/api/comment/public/comment-public.serializer.ts +++ b/src/app/api/comment/public/comment-public.serializer.ts @@ -45,7 +45,7 @@ export class PublicCommentSerializer { const attachmentPaths = attachments.map((attachment) => attachment.filePath) const signedUrls = await PublicCommentSerializer.getFormattedSignedUrls(attachmentPaths) - const promises = attachments.map(async (attachment) => { + return attachments.map((attachment) => { const url = signedUrls.find((item) => item.path === attachment.filePath)?.url return { id: attachment.id, @@ -61,7 +61,6 @@ export class PublicCommentSerializer { uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), } }) - return await Promise.all(promises) } static async serialize(comment: CommentWithAttachments): Promise { From 1e0923a08786e4a85232e78bd1c0fd38800d6d5a Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 12:21:36 +0545 Subject: [PATCH 14/52] feat(OUT-2919): create public api to read single comment of a task - [x] accept comment id as path variable - [x] include attachments in response - [x] serialize response data --- src/app/api/comment/comment.service.ts | 3 ++- src/app/api/comment/public/[id]/route.ts | 4 ++++ .../api/comment/public/comment-public.controller.ts | 11 +++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/app/api/comment/public/[id]/route.ts diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 05d46fd8d..0217f89ad 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -161,9 +161,10 @@ export class CommentService extends BaseService { return comment } - async getCommentById(id: string) { + async getCommentById(id: string, includeAttachments?: boolean) { const comment = await this.db.comment.findFirst({ where: { id, deletedAt: undefined }, // Can also get soft deleted comments + include: { attachments: includeAttachments }, }) if (!comment) return null diff --git a/src/app/api/comment/public/[id]/route.ts b/src/app/api/comment/public/[id]/route.ts new file mode 100644 index 000000000..6c155e5d9 --- /dev/null +++ b/src/app/api/comment/public/[id]/route.ts @@ -0,0 +1,4 @@ +import { getOneCommentPublicForTask } from '@/app/api/comment/public/comment-public.controller' +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' + +export const GET = withErrorHandler(getOneCommentPublicForTask) diff --git a/src/app/api/comment/public/comment-public.controller.ts b/src/app/api/comment/public/comment-public.controller.ts index 9a9f4c521..5b2c49f19 100644 --- a/src/app/api/comment/public/comment-public.controller.ts +++ b/src/app/api/comment/public/comment-public.controller.ts @@ -44,3 +44,14 @@ export const getAllCommentsPublicForTask = async (req: NextRequest, { params }: nextToken: base64NextToken, }) } + +export const getOneCommentPublicForTask = async (req: NextRequest, { params: { id } }: IdParams) => { + const user = await authenticate(req) + + const commentService = new CommentService(user) + const comment = await commentService.getCommentById(id, true) + + if (!comment) return NextResponse.json({ data: null }) + + return NextResponse.json({ data: await PublicCommentSerializer.serialize(comment) }) +} From abd29a9341f16c145110fae673bd3bdffec16d17 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 12:59:16 +0545 Subject: [PATCH 15/52] fix(OUT-2919): await path params --- src/app/api/comment/public/comment-public.controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/api/comment/public/comment-public.controller.ts b/src/app/api/comment/public/comment-public.controller.ts index 5b2c49f19..a7d9c9bfc 100644 --- a/src/app/api/comment/public/comment-public.controller.ts +++ b/src/app/api/comment/public/comment-public.controller.ts @@ -45,7 +45,8 @@ export const getAllCommentsPublicForTask = async (req: NextRequest, { params }: }) } -export const getOneCommentPublicForTask = async (req: NextRequest, { params: { id } }: IdParams) => { +export const getOneCommentPublicForTask = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const commentService = new CommentService(user) From 94f9963bb1180619071fc3bced2663eb73443152 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 17:39:39 +0545 Subject: [PATCH 16/52] refactor(OUT-2919): use object parameter in function --- src/app/api/comment/comment.service.ts | 2 +- src/app/api/comment/public/comment-public.controller.ts | 2 +- src/jobs/notifications/send-reply-create-notifications.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 0217f89ad..a078b1743 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -161,7 +161,7 @@ export class CommentService extends BaseService { return comment } - async getCommentById(id: string, includeAttachments?: boolean) { + async getCommentById({ id, includeAttachments }: { id: string; includeAttachments?: boolean }) { const comment = await this.db.comment.findFirst({ where: { id, deletedAt: undefined }, // Can also get soft deleted comments include: { attachments: includeAttachments }, diff --git a/src/app/api/comment/public/comment-public.controller.ts b/src/app/api/comment/public/comment-public.controller.ts index a7d9c9bfc..cc613dfe3 100644 --- a/src/app/api/comment/public/comment-public.controller.ts +++ b/src/app/api/comment/public/comment-public.controller.ts @@ -50,7 +50,7 @@ export const getOneCommentPublicForTask = async (req: NextRequest, { params }: I const user = await authenticate(req) const commentService = new CommentService(user) - const comment = await commentService.getCommentById(id, true) + const comment = await commentService.getCommentById({ id, includeAttachments: true }) if (!comment) return NextResponse.json({ data: null }) diff --git a/src/jobs/notifications/send-reply-create-notifications.ts b/src/jobs/notifications/send-reply-create-notifications.ts index e9aa01479..def65a295 100644 --- a/src/jobs/notifications/send-reply-create-notifications.ts +++ b/src/jobs/notifications/send-reply-create-notifications.ts @@ -69,7 +69,7 @@ export const sendReplyCreateNotifications = task({ } const commentService = new CommentService(user) - const parentComment = await commentService.getCommentById(comment.parentId) + const parentComment = await commentService.getCommentById({ id: comment.parentId }) if (parentComment) { // Queue notification for parent comment initiator, if: // - Parent Comment hasn't been deleted yet From 4d43464ec929c4696c7fe960407e953657592832 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 14 Jan 2026 19:52:14 +0545 Subject: [PATCH 17/52] feat(OUT-2917): public api to list comments of a task - [x] public api route that gets list of comments for a task - [x] taskId required validation - [x] accept params: taskId, parentCommentId, createdBy - [x] include attachment in reponse with presigned download url --- src/app/api/comment/public/route.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/app/api/comment/public/route.ts diff --git a/src/app/api/comment/public/route.ts b/src/app/api/comment/public/route.ts new file mode 100644 index 000000000..5b52b0b42 --- /dev/null +++ b/src/app/api/comment/public/route.ts @@ -0,0 +1,4 @@ +import { getAllCommentsPublic } from '@/app/api/comment/public/comment-public.controller' +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' + +export const GET = withErrorHandler(getAllCommentsPublic) From 3dba06c8cf9dff47b84c1b6e3dce70f29555b9e6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 19:16:42 +0545 Subject: [PATCH 18/52] feat(OUT-2920): create public API to delete a comment - [x] accept comment id as path variable - [x] delete comment along with attachments associated to it - [x] code refactor by implementing transaction in existing delete function --- .../api/attachments/attachments.service.ts | 14 ++++ src/app/api/comment/comment.service.ts | 64 +++++++++++++------ src/app/api/comment/public/[id]/route.ts | 3 +- .../public/comment-public.controller.ts | 9 +++ 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/src/app/api/attachments/attachments.service.ts b/src/app/api/attachments/attachments.service.ts index 256db7c7c..92c377a09 100644 --- a/src/app/api/attachments/attachments.service.ts +++ b/src/app/api/attachments/attachments.service.ts @@ -86,4 +86,18 @@ export class AttachmentsService extends BaseService { const { data } = await supabase.supabase.storage.from(supabaseBucket).createSignedUrl(filePath, signedUrlTtl) return data?.signedUrl } + + async deleteAttachmentsOfComment(commentId: string) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Delete, Resource.Attachments) + + const commentAttachment = await this.db.attachment.findMany({ + where: { commentId: commentId, workspaceId: this.user.workspaceId }, + }) + await this.db.attachment.deleteMany({ + where: { commentId: commentId, workspaceId: this.user.workspaceId }, + }) + + return commentAttachment + } } diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index a078b1743..ba43429bd 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -1,3 +1,4 @@ +import { AttachmentsService } from '@/app/api/attachments/attachments.service' import { sendCommentCreateNotifications } from '@/jobs/notifications' import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' import { InitiatedEntity } from '@/types/common' @@ -17,7 +18,7 @@ import { PoliciesService } from '@api/core/services/policies.service' import { Resource } from '@api/core/types/api' import { UserAction } from '@api/core/types/user' import { TasksService } from '@api/tasks/tasks.service' -import { ActivityType, Comment, CommentInitiator, Prisma } from '@prisma/client' +import { ActivityType, Comment, CommentInitiator, Prisma, PrismaClient } from '@prisma/client' import httpStatus from 'http-status' import { z } from 'zod' import { AttachmentsService } from '@api/attachments/attachments.service' @@ -103,27 +104,54 @@ export class CommentService extends BaseService { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Delete, Resource.Comment) - const replyCounts = await this.getReplyCounts([id]) - const comment = await this.db.comment.delete({ where: { id } }) + const commentExists = await this.db.comment.findFirst({ where: { id } }) + if (!commentExists) throw new APIError(httpStatus.NOT_FOUND, 'The comment to delete was not found') - // Delete corresponding activity log as well, so as to remove comment from UI - // If activity log exists but comment has a `deletedAt`, show "Comment was deleted" card instead - if (!replyCounts[id]) { - // If there are 0 replies, key won't be in object - await this.deleteRelatedActivityLogs(id) - } + // transaction that deletes the comment and its attachments + const { comment, attachments } = await this.db.$transaction(async (tx) => { + this.setTransaction(tx as PrismaClient) + const comment = await this.db.comment.delete({ where: { id } }) + + // delete the related attachments as well + const attachmentService = new AttachmentsService(this.user) + attachmentService.setTransaction(tx as PrismaClient) + + const attachments = await attachmentService.deleteAttachmentsOfComment(comment.id) + attachmentService.unsetTransaction() + + this.unsetTransaction() + return { comment, attachments } + }) + + // transaction that deletes the activity logs + return await this.db.$transaction(async (tx) => { + this.setTransaction(tx as PrismaClient) + const replyCounts = await this.getReplyCounts([id]) - // If parent comment now has no replies and is also deleted, delete parent as well - if (comment.parentId) { - const parent = await this.db.comment.findFirst({ where: { id: comment.parentId, deletedAt: undefined } }) - if (parent?.deletedAt) { - await this.deleteEmptyParentActivityLog(parent) + // Delete corresponding activity log as well, so as to remove comment from UI + // If activity log exists but comment has a `deletedAt`, show "Comment was deleted" card instead + if (!replyCounts[id]) { + // If there are 0 replies, key won't be in object + await this.deleteRelatedActivityLogs(id) } - } - const tasksService = new TasksService(this.user) - await tasksService.setNewLastActivityLogUpdated(comment.taskId) - return comment + // If parent comment now has no replies and is also deleted, delete parent as well + if (comment.parentId) { + const parent = await this.db.comment.findFirst({ where: { id: comment.parentId, deletedAt: undefined } }) + if (parent?.deletedAt) { + await this.deleteEmptyParentActivityLog(parent) + } + } + + const tasksService = new TasksService(this.user) + tasksService.setTransaction(tx as PrismaClient) + + await tasksService.setNewLastActivityLogUpdated(comment.taskId) + tasksService.unsetTransaction() + + this.unsetTransaction() + return { ...comment, attachments } + }) } private async deleteEmptyParentActivityLog(parent: Comment) { diff --git a/src/app/api/comment/public/[id]/route.ts b/src/app/api/comment/public/[id]/route.ts index 6c155e5d9..095c665c5 100644 --- a/src/app/api/comment/public/[id]/route.ts +++ b/src/app/api/comment/public/[id]/route.ts @@ -1,4 +1,5 @@ -import { getOneCommentPublicForTask } from '@/app/api/comment/public/comment-public.controller' +import { deleteOneCommentPublic, getOneCommentPublicForTask } from '@/app/api/comment/public/comment-public.controller' import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' export const GET = withErrorHandler(getOneCommentPublicForTask) +export const DELETE = withErrorHandler(deleteOneCommentPublic) diff --git a/src/app/api/comment/public/comment-public.controller.ts b/src/app/api/comment/public/comment-public.controller.ts index cc613dfe3..aab068853 100644 --- a/src/app/api/comment/public/comment-public.controller.ts +++ b/src/app/api/comment/public/comment-public.controller.ts @@ -56,3 +56,12 @@ export const getOneCommentPublicForTask = async (req: NextRequest, { params }: I return NextResponse.json({ data: await PublicCommentSerializer.serialize(comment) }) } + +export const deleteOneCommentPublic = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params + const user = await authenticate(req) + + const commentService = new CommentService(user) + const deletedComment = await commentService.delete(id) + return NextResponse.json({ ...(await PublicCommentSerializer.serialize(deletedComment)) }) +} From fa35fc3cc073325a6e200d6d9be663dab6d50475 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 21 Jan 2026 14:01:36 +0545 Subject: [PATCH 19/52] fix(OUT-2920): remove double file import --- src/app/api/comment/comment.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index ba43429bd..905c8e91e 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -1,4 +1,3 @@ -import { AttachmentsService } from '@/app/api/attachments/attachments.service' import { sendCommentCreateNotifications } from '@/jobs/notifications' import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' import { InitiatedEntity } from '@/types/common' From 5c466c43699d8f4f16244c99a067cc60d3f0ce4b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 21 Jan 2026 14:04:41 +0545 Subject: [PATCH 20/52] fix(OUT-2920): file import error --- src/app/api/comment/public/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/comment/public/route.ts b/src/app/api/comment/public/route.ts index 5b52b0b42..56fa05626 100644 --- a/src/app/api/comment/public/route.ts +++ b/src/app/api/comment/public/route.ts @@ -1,4 +1,4 @@ -import { getAllCommentsPublic } from '@/app/api/comment/public/comment-public.controller' +import { getAllCommentsPublicForTask } from '@/app/api/comment/public/comment-public.controller' import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' -export const GET = withErrorHandler(getAllCommentsPublic) +export const GET = withErrorHandler(getAllCommentsPublicForTask) From 520515c41619bc58d3d101f32159a08a2a291b08 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 21 Jan 2026 16:58:53 +0545 Subject: [PATCH 21/52] feat(OUT-2938): secure public comments api - [x] check comment task permission. User with permission to access task should have access to its comments - [x] refactor: function name change --- src/app/api/comment/comment.service.ts | 23 ++++++++++++++++ src/app/api/comment/public/[id]/route.ts | 5 ---- .../public/comment-public.controller.ts | 27 ++++++++++++------- src/app/api/comment/public/route.ts | 4 --- .../public/[id]/comments/[commentId]/route.ts | 5 ++++ .../api/tasks/public/[id]/comments/route.ts | 4 +-- 6 files changed, 48 insertions(+), 20 deletions(-) delete mode 100644 src/app/api/comment/public/[id]/route.ts delete mode 100644 src/app/api/comment/public/route.ts create mode 100644 src/app/api/tasks/public/[id]/comments/[commentId]/route.ts diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 905c8e91e..5ae357841 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -22,6 +22,7 @@ import httpStatus from 'http-status' import { z } from 'zod' import { AttachmentsService } from '@api/attachments/attachments.service' import { getSignedUrl } from '@/utils/signUrl' +import { PublicTasksService } from '@/app/api/tasks/public/public.service' export class CommentService extends BaseService { async create(data: CreateComment) { @@ -426,4 +427,26 @@ export class CommentService extends BaseService { }) return !!newComment } + + /** + * If the user has permission to access the task, it means the user has access to the task's comments + * Therefore checking the task permission + */ + async checkCommentTaskPermissionForUser(taskId: string) { + try { + const publicTask = new PublicTasksService(this.user) + await publicTask.getOneTask(taskId) + } catch (err: unknown) { + if (err instanceof APIError) { + let status: number = httpStatus.UNAUTHORIZED, + message = 'You are not authorized to perform this action' + if (err.status === httpStatus.NOT_FOUND) { + status = httpStatus.NOT_FOUND + message = 'A task for the requested comment was not found' + } + throw new APIError(status, message) + } + throw err + } + } } diff --git a/src/app/api/comment/public/[id]/route.ts b/src/app/api/comment/public/[id]/route.ts deleted file mode 100644 index 095c665c5..000000000 --- a/src/app/api/comment/public/[id]/route.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { deleteOneCommentPublic, getOneCommentPublicForTask } from '@/app/api/comment/public/comment-public.controller' -import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' - -export const GET = withErrorHandler(getOneCommentPublicForTask) -export const DELETE = withErrorHandler(deleteOneCommentPublic) diff --git a/src/app/api/comment/public/comment-public.controller.ts b/src/app/api/comment/public/comment-public.controller.ts index aab068853..774e9e3f9 100644 --- a/src/app/api/comment/public/comment-public.controller.ts +++ b/src/app/api/comment/public/comment-public.controller.ts @@ -9,8 +9,12 @@ import { CommentsPublicFilterType } from '@/types/dto/comment.dto' import { IdParams } from '@/app/api/core/types/api' import { getPaginationLimit } from '@/utils/pagination' -export const getAllCommentsPublicForTask = async (req: NextRequest, { params }: IdParams) => { - const { id } = await params +type TaskAndCommentIdParams = { + params: Promise<{ id: string; commentId: string }> +} + +export const getAllCommentsPublic = async (req: NextRequest, { params }: IdParams) => { + const { id: taskId } = await params const user = await authenticate(req) const { parentCommentId, createdBy, limit, nextToken } = getSearchParams(req.nextUrl.searchParams, [ @@ -21,12 +25,14 @@ export const getAllCommentsPublicForTask = async (req: NextRequest, { params }: ]) const publicFilters: CommentsPublicFilterType = { - taskId: id, + taskId, parentId: parentCommentId || undefined, initiatorId: createdBy || undefined, } const commentService = new CommentService(user) + await commentService.checkCommentTaskPermissionForUser(taskId) // check the user accessing the comment has access to the task + const comments = await commentService.getAllComments({ limit: getPaginationLimit(limit), lastIdCursor: nextToken ? decode(nextToken) : undefined, @@ -45,23 +51,26 @@ export const getAllCommentsPublicForTask = async (req: NextRequest, { params }: }) } -export const getOneCommentPublicForTask = async (req: NextRequest, { params }: IdParams) => { - const { id } = await params +export const getOneCommentPublic = async (req: NextRequest, { params }: TaskAndCommentIdParams) => { + const { id: taskId, commentId } = await params const user = await authenticate(req) const commentService = new CommentService(user) - const comment = await commentService.getCommentById({ id, includeAttachments: true }) + await commentService.checkCommentTaskPermissionForUser(taskId) // check the user accessing the comment has access to the task + const comment = await commentService.getCommentById({ id: commentId, includeAttachments: true }) if (!comment) return NextResponse.json({ data: null }) return NextResponse.json({ data: await PublicCommentSerializer.serialize(comment) }) } -export const deleteOneCommentPublic = async (req: NextRequest, { params }: IdParams) => { - const { id } = await params +export const deleteOneCommentPublic = async (req: NextRequest, { params }: TaskAndCommentIdParams) => { + const { id: taskId, commentId } = await params const user = await authenticate(req) const commentService = new CommentService(user) - const deletedComment = await commentService.delete(id) + await commentService.checkCommentTaskPermissionForUser(taskId) // check the user accessing the comment has access to the task + + const deletedComment = await commentService.delete(commentId) return NextResponse.json({ ...(await PublicCommentSerializer.serialize(deletedComment)) }) } diff --git a/src/app/api/comment/public/route.ts b/src/app/api/comment/public/route.ts deleted file mode 100644 index 56fa05626..000000000 --- a/src/app/api/comment/public/route.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { getAllCommentsPublicForTask } from '@/app/api/comment/public/comment-public.controller' -import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' - -export const GET = withErrorHandler(getAllCommentsPublicForTask) diff --git a/src/app/api/tasks/public/[id]/comments/[commentId]/route.ts b/src/app/api/tasks/public/[id]/comments/[commentId]/route.ts new file mode 100644 index 000000000..228213ee3 --- /dev/null +++ b/src/app/api/tasks/public/[id]/comments/[commentId]/route.ts @@ -0,0 +1,5 @@ +import { deleteOneCommentPublic, getOneCommentPublic } from '@/app/api/comment/public/comment-public.controller' +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' + +export const GET = withErrorHandler(getOneCommentPublic) +export const DELETE = withErrorHandler(deleteOneCommentPublic) diff --git a/src/app/api/tasks/public/[id]/comments/route.ts b/src/app/api/tasks/public/[id]/comments/route.ts index 56fa05626..5b52b0b42 100644 --- a/src/app/api/tasks/public/[id]/comments/route.ts +++ b/src/app/api/tasks/public/[id]/comments/route.ts @@ -1,4 +1,4 @@ -import { getAllCommentsPublicForTask } from '@/app/api/comment/public/comment-public.controller' +import { getAllCommentsPublic } from '@/app/api/comment/public/comment-public.controller' import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' -export const GET = withErrorHandler(getAllCommentsPublicForTask) +export const GET = withErrorHandler(getAllCommentsPublic) From 94809aa3a3deb6dd47b3dcf1998a70acd794fcdb Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 19:16:42 +0545 Subject: [PATCH 22/52] feat(OUT-2920): create public API to delete a comment - [x] accept comment id as path variable - [x] delete comment along with attachments associated to it - [x] code refactor by implementing transaction in existing delete function --- src/app/api/comment/comment.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 5ae357841..a21c51e28 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -1,3 +1,4 @@ +import { AttachmentsService } from '@/app/api/attachments/attachments.service' import { sendCommentCreateNotifications } from '@/jobs/notifications' import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' import { InitiatedEntity } from '@/types/common' From 6a829637d8a4b251517a0a9293ec3004cdcc8a0e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 16 Jan 2026 17:56:10 +0545 Subject: [PATCH 23/52] feat(OUT-2940): delete attachments from bucket when a comment is deleted - [x] directly delete comment attachments from bucket when the comment is deleted - [x] refactor: create separate function that handles attachment deletion --- .../api/attachments/attachments.service.ts | 25 ++++++++++++++----- src/app/api/comment/comment.service.ts | 21 +++++----------- src/app/api/core/services/supabase.service.ts | 13 ++++++++++ .../scrap-medias/scrap-medias.service.ts | 13 +++++----- 4 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/app/api/attachments/attachments.service.ts b/src/app/api/attachments/attachments.service.ts index 92c377a09..1583eefdb 100644 --- a/src/app/api/attachments/attachments.service.ts +++ b/src/app/api/attachments/attachments.service.ts @@ -9,6 +9,7 @@ import APIError from '@api/core/exceptions/api' import httpStatus from 'http-status' import { SupabaseService } from '@api/core/services/supabase.service' import { signedUrlTtl } from '@/constants/attachments' +import { PrismaClient } from '@prisma/client' export class AttachmentsService extends BaseService { async getAttachments(taskId: string) { @@ -91,13 +92,25 @@ export class AttachmentsService extends BaseService { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Delete, Resource.Attachments) - const commentAttachment = await this.db.attachment.findMany({ - where: { commentId: commentId, workspaceId: this.user.workspaceId }, - }) - await this.db.attachment.deleteMany({ - where: { commentId: commentId, workspaceId: this.user.workspaceId }, + const commentAttachment = await this.db.$transaction(async (tx) => { + this.setTransaction(tx as PrismaClient) + + const commentAttachment = await this.db.attachment.findMany({ + where: { commentId: commentId, workspaceId: this.user.workspaceId }, + }) + + await this.db.attachment.deleteMany({ + where: { commentId: commentId, workspaceId: this.user.workspaceId }, + }) + + this.unsetTransaction() + return commentAttachment }) - return commentAttachment + // directly delete attachments from bucket when deleting comments. + // Postgres transaction is not valid for supabase object so placing it after record deletion from db + const filePathArray = commentAttachment.map((el) => el.filePath) + const supabase = new SupabaseService() + await supabase.removeAttachmentsFromBucket(filePathArray) } } diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index a21c51e28..9a77d33b4 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -108,21 +108,12 @@ export class CommentService extends BaseService { const commentExists = await this.db.comment.findFirst({ where: { id } }) if (!commentExists) throw new APIError(httpStatus.NOT_FOUND, 'The comment to delete was not found') - // transaction that deletes the comment and its attachments - const { comment, attachments } = await this.db.$transaction(async (tx) => { - this.setTransaction(tx as PrismaClient) - const comment = await this.db.comment.delete({ where: { id } }) - - // delete the related attachments as well - const attachmentService = new AttachmentsService(this.user) - attachmentService.setTransaction(tx as PrismaClient) - - const attachments = await attachmentService.deleteAttachmentsOfComment(comment.id) - attachmentService.unsetTransaction() + // delete the comment + const comment = await this.db.comment.delete({ where: { id } }) - this.unsetTransaction() - return { comment, attachments } - }) + // delete the related attachments as well + const attachmentService = new AttachmentsService(this.user) + await attachmentService.deleteAttachmentsOfComment(comment.id) // transaction that deletes the activity logs return await this.db.$transaction(async (tx) => { @@ -151,7 +142,7 @@ export class CommentService extends BaseService { tasksService.unsetTransaction() this.unsetTransaction() - return { ...comment, attachments } + return { ...comment, attachments: [] } // send empty attachments array }) } diff --git a/src/app/api/core/services/supabase.service.ts b/src/app/api/core/services/supabase.service.ts index 3cd49967f..f41031668 100644 --- a/src/app/api/core/services/supabase.service.ts +++ b/src/app/api/core/services/supabase.service.ts @@ -1,9 +1,22 @@ +import APIError from '@/app/api/core/exceptions/api' +import { supabaseBucket } from '@/config' import SupabaseClient from '@/lib/supabase' import { type SupabaseClient as SupabaseJSClient } from '@supabase/supabase-js' +import httpStatus from 'http-status' /** * Base Service with access to supabase client */ export class SupabaseService { public supabase: SupabaseJSClient = SupabaseClient.getInstance() + + async removeAttachmentsFromBucket(attachmentsToDelete: string[]) { + if (attachmentsToDelete.length !== 0) { + const { error } = await this.supabase.storage.from(supabaseBucket).remove(attachmentsToDelete) + if (error) { + console.error(error) + throw new APIError(httpStatus.NOT_FOUND, 'unable to delete some date from supabase') + } + } + } } diff --git a/src/app/api/workers/scrap-medias/scrap-medias.service.ts b/src/app/api/workers/scrap-medias/scrap-medias.service.ts index b9c25e3e5..ca637f7e8 100644 --- a/src/app/api/workers/scrap-medias/scrap-medias.service.ts +++ b/src/app/api/workers/scrap-medias/scrap-medias.service.ts @@ -77,14 +77,13 @@ export class ScrapMediaService { console.error('Error processing scrap image', e) } } - if (scrapMediasToDeleteFromBucket.length !== 0) { - const { error } = await supabase.supabase.storage.from(supabaseBucket).remove(scrapMediasToDeleteFromBucket) - if (error) { - console.error(error) - throw new APIError(404, 'unable to delete some date from supabase') - } + + if (!!scrapMediasToDeleteFromBucket.length) await db.attachment.deleteMany({ where: { filePath: { in: scrapMediasToDeleteFromBucket } } }) - } + + // remove attachments from bucket + await supabase.removeAttachmentsFromBucket(scrapMediasToDeleteFromBucket) + if (scrapMediasToDelete.length !== 0) { const idsToDelete = scrapMediasToDelete.map((id) => `'${id}'`).join(', ') await db.$executeRawUnsafe(` From c0bf72d6b5773045ddedf9cbc30cd2f62cdcd875 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 21 Jan 2026 14:32:53 +0545 Subject: [PATCH 24/52] fix(OUT-2940): remove double file import --- src/app/api/comment/comment.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 9a77d33b4..c980a5c9c 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -1,4 +1,3 @@ -import { AttachmentsService } from '@/app/api/attachments/attachments.service' import { sendCommentCreateNotifications } from '@/jobs/notifications' import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' import { InitiatedEntity } from '@/types/common' From cafba8baef970c27800abe5499ac961639fcd6cc Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 22 Jan 2026 16:08:26 +0545 Subject: [PATCH 25/52] fix(OUT-2940): not create sign url when attachment is deleted --- src/app/api/comment/public/comment-public.dto.ts | 4 +++- .../api/comment/public/comment-public.serializer.ts | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/app/api/comment/public/comment-public.dto.ts b/src/app/api/comment/public/comment-public.dto.ts index 2bf52547a..9fa643265 100644 --- a/src/app/api/comment/public/comment-public.dto.ts +++ b/src/app/api/comment/public/comment-public.dto.ts @@ -7,10 +7,11 @@ export const PublicAttachmentDtoSchema = z.object({ fileName: z.string(), fileSize: z.number(), mimeType: z.string(), - downloadUrl: z.string().url(), + downloadUrl: z.string().url().nullable(), uploadedBy: z.string().uuid(), uploadedByUserType: z.nativeEnum(AssigneeType).nullable(), uploadedDate: RFC3339DateSchema, + deletedDate: RFC3339DateSchema.nullable(), }) export type PublicAttachmentDto = z.infer @@ -24,6 +25,7 @@ export const PublicCommentDtoSchema = z.object({ createdByUserType: z.nativeEnum(AssigneeType).nullable(), createdDate: RFC3339DateSchema, updatedDate: RFC3339DateSchema, + deletedDate: RFC3339DateSchema.nullable(), attachments: z.array(PublicAttachmentDtoSchema).nullable(), }) export type PublicCommentDto = z.infer diff --git a/src/app/api/comment/public/comment-public.serializer.ts b/src/app/api/comment/public/comment-public.serializer.ts index e5a3295fd..6dd19c2eb 100644 --- a/src/app/api/comment/public/comment-public.serializer.ts +++ b/src/app/api/comment/public/comment-public.serializer.ts @@ -18,6 +18,7 @@ export class PublicCommentSerializer { createdByUserType: comment.initiatorType, createdDate: RFC3339DateSchema.parse(toRFC3339(comment.createdAt)), updatedDate: RFC3339DateSchema.parse(toRFC3339(comment.updatedAt)), + deletedDate: toRFC3339(comment.deletedAt), attachments: await PublicCommentSerializer.serializeAttachments({ attachments: comment.attachments, uploadedByUserType: comment.initiatorType, @@ -52,13 +53,16 @@ export class PublicCommentSerializer { fileName: attachment.fileName, fileSize: attachment.fileSize, mimeType: attachment.fileType, - downloadUrl: z - .string() - .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) - .parse(url), + downloadUrl: attachment.deletedAt + ? null + : z + .string() + .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) + .parse(url), uploadedBy, uploadedByUserType, uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), + deletedDate: toRFC3339(attachment.deletedAt), } }) } From 50ed7d514f9b4356f64f764cf76815503b757818 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 19 Jan 2026 13:30:07 +0545 Subject: [PATCH 26/52] feat(OUT-2921): dispatch webhook event when comment added on task - [x] create dispatchable event commend is created - [x] create a function that returns the comment attachments --- src/app/api/attachments/attachments.service.ts | 13 +++++++++++++ src/app/api/comment/comment.service.ts | 10 ++++++++++ src/types/webhook.ts | 1 + 3 files changed, 24 insertions(+) diff --git a/src/app/api/attachments/attachments.service.ts b/src/app/api/attachments/attachments.service.ts index 1583eefdb..d5fefdfe0 100644 --- a/src/app/api/attachments/attachments.service.ts +++ b/src/app/api/attachments/attachments.service.ts @@ -25,6 +25,19 @@ export class AttachmentsService extends BaseService { return attachments } + async getAttachmentsForComment(commentId: string) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Read, Resource.Attachments) + const attachments = await this.db.attachment.findMany({ + where: { + commentId, + workspaceId: this.user.workspaceId, + }, + }) + + return attachments + } + async createAttachments(data: CreateAttachmentRequest) { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Create, Resource.Attachments) diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index c980a5c9c..6a3b3a9a6 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -1,8 +1,11 @@ +import { AttachmentsService } from '@/app/api/attachments/attachments.service' +import { PublicCommentSerializer } from '@/app/api/comment/public/comment-public.serializer' import { sendCommentCreateNotifications } from '@/jobs/notifications' import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' import { InitiatedEntity } from '@/types/common' import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' import { CommentsPublicFilterType, CommentWithAttachments, CreateComment, UpdateComment } from '@/types/dto/comment.dto' +import { DISPATCHABLE_EVENT } from '@/types/webhook' import { getArrayDifference, getArrayIntersection } from '@/utils/array' import { getFileNameFromPath } from '@/utils/attachmentUtils' import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' @@ -91,6 +94,13 @@ export class CommentService extends BaseService { ]) } + // dispatch a webhook event when comment is created + const attachments = await new AttachmentsService(this.user).getAttachmentsForComment(comment.id) + await this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.CommentCreated, { + payload: await PublicCommentSerializer.serialize({ ...comment, attachments }), + workspaceId: this.user.workspaceId, + }) + return comment // if (data.mentions) { diff --git a/src/types/webhook.ts b/src/types/webhook.ts index c396087dd..c3b43791d 100644 --- a/src/types/webhook.ts +++ b/src/types/webhook.ts @@ -14,6 +14,7 @@ export enum DISPATCHABLE_EVENT { TaskUpdated = 'task.updated', TaskCompleted = 'task.completed', TaskDeleted = 'task.deleted', + CommentCreated = 'comment.created', } export const WebhookSchema = z.object({ From 528d70d3e8473fc127e8a475702a9fd7a1aab541 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 19 Jan 2026 14:13:48 +0545 Subject: [PATCH 27/52] refactor(OUT-2921): include attachments in create comment response --- src/app/api/attachments/attachments.service.ts | 13 ------------- src/app/api/comment/comment.service.ts | 4 ++-- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/app/api/attachments/attachments.service.ts b/src/app/api/attachments/attachments.service.ts index d5fefdfe0..1583eefdb 100644 --- a/src/app/api/attachments/attachments.service.ts +++ b/src/app/api/attachments/attachments.service.ts @@ -25,19 +25,6 @@ export class AttachmentsService extends BaseService { return attachments } - async getAttachmentsForComment(commentId: string) { - const policyGate = new PoliciesService(this.user) - policyGate.authorize(UserAction.Read, Resource.Attachments) - const attachments = await this.db.attachment.findMany({ - where: { - commentId, - workspaceId: this.user.workspaceId, - }, - }) - - return attachments - } - async createAttachments(data: CreateAttachmentRequest) { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Create, Resource.Attachments) diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 6a3b3a9a6..1e6ef77b1 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -53,6 +53,7 @@ export class CommentService extends BaseService { // This is safe to do, since if user doesn't have both iu ID / client ID, they will be filtered out way before initiatorType, }, + include: { attachments: true }, }) try { @@ -95,9 +96,8 @@ export class CommentService extends BaseService { } // dispatch a webhook event when comment is created - const attachments = await new AttachmentsService(this.user).getAttachmentsForComment(comment.id) await this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.CommentCreated, { - payload: await PublicCommentSerializer.serialize({ ...comment, attachments }), + payload: await PublicCommentSerializer.serialize(comment), workspaceId: this.user.workspaceId, }) From e93f7d03befd828be9acd89ba82359791e9fc210 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 12:07:34 +0545 Subject: [PATCH 28/52] fix(OUT-2921): remove double file import --- src/app/api/comment/comment.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 1e6ef77b1..506c6393d 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -23,7 +23,6 @@ import { TasksService } from '@api/tasks/tasks.service' import { ActivityType, Comment, CommentInitiator, Prisma, PrismaClient } from '@prisma/client' import httpStatus from 'http-status' import { z } from 'zod' -import { AttachmentsService } from '@api/attachments/attachments.service' import { getSignedUrl } from '@/utils/signUrl' import { PublicTasksService } from '@/app/api/tasks/public/public.service' From 116262172697d4de3b2a00fb1537f0d4a0fd8083 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 19 Jan 2026 16:10:21 +0545 Subject: [PATCH 29/52] feat(OUT-2923): include attachments attribute in public tasks api - [x] include non deleted attributes in response for list, get one, create, update --- .../public/attachment-public.dto.ts | 16 ++++++ .../public/attachment-public.serializer.ts | 51 +++++++++++++++++++ src/app/api/tasks/public/public.controller.ts | 10 ++-- src/app/api/tasks/public/public.dto.ts | 2 + src/app/api/tasks/public/public.serializer.ts | 19 ++++--- src/app/api/tasks/public/public.service.ts | 50 ++++++++++++++---- src/app/api/tasks/tasks.helpers.ts | 6 +-- src/app/api/tasks/tasks.service.ts | 32 +++++++++--- .../queue-task-update-backlog-webhook.ts | 9 +++- 9 files changed, 163 insertions(+), 32 deletions(-) create mode 100644 src/app/api/attachments/public/attachment-public.dto.ts create mode 100644 src/app/api/attachments/public/attachment-public.serializer.ts diff --git a/src/app/api/attachments/public/attachment-public.dto.ts b/src/app/api/attachments/public/attachment-public.dto.ts new file mode 100644 index 000000000..62d9e7905 --- /dev/null +++ b/src/app/api/attachments/public/attachment-public.dto.ts @@ -0,0 +1,16 @@ +import { RFC3339DateSchema } from '@/types/common' +import { AssigneeType } from '@prisma/client' +import z from 'zod' + +export const PublicAttachmentDtoSchema = z.object({ + id: z.string().uuid(), + fileName: z.string(), + fileSize: z.number(), + mimeType: z.string(), + downloadUrl: z.string().url(), + uploadedBy: z.string().uuid(), + uploadedByUserType: z.nativeEnum(AssigneeType).nullable(), + uploadedDate: RFC3339DateSchema, +}) + +export type PublicAttachmentDto = z.infer diff --git a/src/app/api/attachments/public/attachment-public.serializer.ts b/src/app/api/attachments/public/attachment-public.serializer.ts new file mode 100644 index 000000000..f77e5897f --- /dev/null +++ b/src/app/api/attachments/public/attachment-public.serializer.ts @@ -0,0 +1,51 @@ +import { PublicAttachmentDto } from '@/app/api/attachments/public/attachment-public.dto' +import { RFC3339DateSchema } from '@/types/common' +import { toRFC3339 } from '@/utils/dateHelper' +import { createSignedUrls } from '@/utils/signUrl' +import { Attachment, CommentInitiator } from '@prisma/client' +import z from 'zod' + +export class PublicAttachmentSerializer { + /** + * + * @param attachments array of Attachment + * @param uploadedBy id of the one who commented + * @param uploadedByUserType usertype of the one who commented + * @returns Array of PublicAttachmentDto + */ + static async serializeAttachments({ + attachments, + uploadedByUserType, + uploadedBy, + }: { + attachments: Attachment[] + uploadedByUserType?: CommentInitiator | null + uploadedBy?: string + }): Promise { + const attachmentPaths = attachments.map((attachment) => attachment.filePath) + const signedUrls = await PublicAttachmentSerializer.getFormattedSignedUrls(attachmentPaths) + + return attachments.map((attachment) => { + const url = signedUrls.find((item) => item.path === attachment.filePath)?.url + return { + id: attachment.id, + fileName: attachment.fileName, + fileSize: attachment.fileSize, + mimeType: attachment.fileType, + downloadUrl: z + .string() + .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) + .parse(url), + uploadedBy: uploadedBy || attachment.createdById, + uploadedByUserType: uploadedByUserType || 'internalUser', // todo: 'internalUser' literal needs to be changed later once uploadedByUserType column is introduced in attachments table + uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), + } + }) + } + + static async getFormattedSignedUrls(attachmentPaths: string[]) { + if (!attachmentPaths.length) return [] + const signedUrls = await createSignedUrls(attachmentPaths) + return signedUrls.map((item) => ({ path: item.path, url: item.signedUrl })) + } +} diff --git a/src/app/api/tasks/public/public.controller.ts b/src/app/api/tasks/public/public.controller.ts index 0638e7fde..0e7ade4a1 100644 --- a/src/app/api/tasks/public/public.controller.ts +++ b/src/app/api/tasks/public/public.controller.ts @@ -47,7 +47,7 @@ export const getAllTasksPublic = async (req: NextRequest) => { const base64NextToken = hasMoreTasks ? encode(lastTaskId) : undefined return NextResponse.json({ - data: PublicTaskSerializer.serializeMany(tasks), + data: await PublicTaskSerializer.serializeMany(tasks), nextToken: base64NextToken, }) } @@ -57,7 +57,7 @@ export const getOneTaskPublic = async (req: NextRequest, { params }: IdParams) = const user = await authenticate(req) const tasksService = new PublicTasksService(user) const task = await tasksService.getOneTask(id) - return NextResponse.json(PublicTaskSerializer.serialize(task)) + return NextResponse.json(await PublicTaskSerializer.serialize(task)) } export const createTaskPublic = async (req: NextRequest) => { @@ -73,7 +73,7 @@ export const createTaskPublic = async (req: NextRequest) => { const newTask = await tasksService.createTask(createPayload) console.info('Created new public task:', newTask) - return NextResponse.json(PublicTaskSerializer.serialize(newTask)) + return NextResponse.json(await PublicTaskSerializer.serialize(newTask)) } export const updateTaskPublic = async (req: NextRequest, { params }: IdParams) => { @@ -85,7 +85,7 @@ export const updateTaskPublic = async (req: NextRequest, { params }: IdParams) = const updatePayload = await PublicTaskSerializer.deserializeUpdatePayload(data, user.workspaceId) const updatedTask = await tasksService.updateTask(id, updatePayload) - return NextResponse.json(PublicTaskSerializer.serialize(updatedTask)) + return NextResponse.json(await PublicTaskSerializer.serialize(updatedTask)) } export const deleteOneTaskPublic = async (req: NextRequest, { params }: IdParams) => { @@ -94,5 +94,5 @@ export const deleteOneTaskPublic = async (req: NextRequest, { params }: IdParams const user = await authenticate(req) const tasksService = new PublicTasksService(user) const task = await tasksService.deleteTask(id, z.coerce.boolean().parse(recursive)) - return NextResponse.json({ ...PublicTaskSerializer.serialize(task) }) + return NextResponse.json({ ...(await PublicTaskSerializer.serialize(task)) }) } diff --git a/src/app/api/tasks/public/public.dto.ts b/src/app/api/tasks/public/public.dto.ts index bd0d5081a..6886cab1c 100644 --- a/src/app/api/tasks/public/public.dto.ts +++ b/src/app/api/tasks/public/public.dto.ts @@ -3,6 +3,7 @@ import { CopilotAPI } from '@/utils/CopilotAPI' import { AssigneeType } from '@prisma/client' import { z } from 'zod' import { validateUserIds, ViewersSchema } from '@/types/dto/tasks.dto' +import { PublicAttachmentDtoSchema } from '@/app/api/attachments/public/attachment-public.dto' export const TaskSourceSchema = z.enum(['web', 'api']) export type TaskSource = z.infer @@ -41,6 +42,7 @@ export const PublicTaskDtoSchema = z.object({ clientId: z.string().uuid().nullable(), companyId: z.string().uuid().nullable(), viewers: ViewersSchema, + attachments: z.array(PublicAttachmentDtoSchema), }) export type PublicTaskDto = z.infer diff --git a/src/app/api/tasks/public/public.serializer.ts b/src/app/api/tasks/public/public.serializer.ts index 6b727aba2..f1cdcd983 100644 --- a/src/app/api/tasks/public/public.serializer.ts +++ b/src/app/api/tasks/public/public.serializer.ts @@ -1,3 +1,4 @@ +import { PublicAttachmentSerializer } from '@/app/api/attachments/public/attachment-public.serializer' import APIError from '@/app/api/core/exceptions/api' import DBClient from '@/lib/db' import { RFC3339DateSchema } from '@/types/common' @@ -13,7 +14,7 @@ import { copyTemplateMediaToTask } from '@/utils/signedTemplateUrlReplacer' import { replaceImageSrc } from '@/utils/signedUrlReplacer' import { getSignedUrl } from '@/utils/signUrl' import { PublicTaskCreateDto, PublicTaskDto, PublicTaskDtoSchema, PublicTaskUpdateDto } from '@api/tasks/public/public.dto' -import { Task, WorkflowState } from '@prisma/client' +import { Attachment, Task, WorkflowState } from '@prisma/client' import httpStatus from 'http-status' import { z } from 'zod' @@ -31,8 +32,10 @@ export const workflowStateTypeMap: Record { return { id: task.id, object: 'task', @@ -60,15 +63,19 @@ export class PublicTaskSerializer { clientId: task.clientId, companyId: task.companyId, viewers: ViewersSchema.parse(task.viewers), + attachments: await PublicAttachmentSerializer.serializeAttachments({ + attachments: task.attachments, + }), } } - static serialize(task: Task & { workflowState: WorkflowState }): PublicTaskDto { - return PublicTaskDtoSchema.parse(PublicTaskSerializer.serializeUnsafe(task)) + static async serialize(task: TaskWithWorkflowStateAndAttachments): Promise { + return PublicTaskDtoSchema.parse(await PublicTaskSerializer.serializeUnsafe(task)) } - static serializeMany(tasks: (Task & { workflowState: WorkflowState })[]): PublicTaskDto[] { - return z.array(PublicTaskDtoSchema).parse(tasks.map((task) => PublicTaskSerializer.serializeUnsafe(task))) + static async serializeMany(tasks: TaskWithWorkflowStateAndAttachments[]): Promise { + const serializedTasks = await Promise.all(tasks.map(async (task) => PublicTaskSerializer.serializeUnsafe(task))) + return z.array(PublicTaskDtoSchema).parse(serializedTasks) } static async getWorkflowStateIdForStatus( diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index d1fef0c3e..f7f79abe0 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -23,7 +23,7 @@ import { LabelMappingService } from '@api/label-mapping/label-mapping.service' import { SubtaskService } from '@api/tasks/subtasks.service' import { TasksActivityLogger } from '@api/tasks/tasks.logger' import { TemplatesService } from '@api/tasks/templates/templates.service' -import { PublicTaskSerializer } from '@api/tasks/public/public.serializer' +import { PublicTaskSerializer, TaskWithWorkflowStateAndAttachments } from '@api/tasks/public/public.serializer' import { getBasicPaginationAttributes } from '@/utils/pagination' export class PublicTasksService extends TasksSharedService { @@ -40,7 +40,7 @@ export class PublicTasksService extends TasksSharedService { workflowState?: { type: StateType | { not: StateType } } limit?: number lastIdCursor?: string - }): Promise { + }): Promise { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Read, Resource.Tasks) @@ -88,13 +88,18 @@ export class PublicTasksService extends TasksSharedService { orderBy, ...pagination, relationLoadStrategy: 'join', - include: { workflowState: true }, + include: { + workflowState: true, + attachments: { + where: { commentId: null, deletedAt: null }, + }, + }, }) return tasks } - async getOneTask(id: string): Promise { + async getOneTask(id: string): Promise { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Read, Resource.Tasks) @@ -102,7 +107,19 @@ export class PublicTasksService extends TasksSharedService { // while clients can only view the tasks assigned to them or their company const filters = this.buildTaskPermissions(id) const where = { ...filters, deletedAt: { not: undefined } } - const task = await this.db.task.findFirst({ where, relationLoadStrategy: 'join', include: { workflowState: true } }) + const task = await this.db.task.findFirst({ + where, + relationLoadStrategy: 'join', + include: { + workflowState: true, + attachments: { + where: { commentId: null, deletedAt: null }, + }, + }, + }) + + console.info({ task, attachment: task?.attachments }) + if (!task) throw new APIError(httpStatus.NOT_FOUND, 'The requested task was not found') if (this.user.internalUserId) { await this.checkClientAccessForTask(task, this.user.internalUserId) @@ -184,7 +201,12 @@ export class PublicTasksService extends TasksSharedService { ...(opts?.manualTimestamp && { createdAt: opts.manualTimestamp }), ...(await getTaskTimestamps('create', this.user, data, undefined, workflowStateStatus)), }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: { + where: { commentId: null, deletedAt: null }, + }, + }, }) console.info('PublicTasksService#createTask | Task created with ID:', newTask.id) @@ -237,7 +259,7 @@ export class PublicTasksService extends TasksSharedService { await Promise.all([ sendTaskCreateNotifications.trigger({ user: this.user, task: newTask }), this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskCreated, { - payload: PublicTaskSerializer.serialize(newTask), + payload: await PublicTaskSerializer.serialize(newTask), workspaceId: this.user.workspaceId, }), ]) @@ -360,7 +382,12 @@ export class PublicTasksService extends TasksSharedService { ...userAssignmentFields, ...(await getTaskTimestamps('update', this.user, data, prevTask)), }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: { + where: { commentId: null, deletedAt: null }, + }, + }, }) subtaskService.setTransaction(tx as PrismaClient) // Archive / unarchive all subtasks if parent task is archived / unarchived @@ -429,15 +456,18 @@ export class PublicTasksService extends TasksSharedService { return deletedTask }) + // Todo: delete attachments from bucket when task is deleted + const taskWithAttachment = { ...updatedTask, attachments: [] } // empty attachments array for deleted tasks + await Promise.all([ deleteTaskNotifications.trigger({ user: this.user, task }), this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskDeleted, { - payload: PublicTaskSerializer.serialize(updatedTask), + payload: await PublicTaskSerializer.serialize(taskWithAttachment), workspaceId: this.user.workspaceId, }), ]) - return updatedTask + return taskWithAttachment // Logic to remove internal user notifications when a task is deleted / assignee is deleted // ...In case requirements change later again diff --git a/src/app/api/tasks/tasks.helpers.ts b/src/app/api/tasks/tasks.helpers.ts index 505d10569..80f113215 100644 --- a/src/app/api/tasks/tasks.helpers.ts +++ b/src/app/api/tasks/tasks.helpers.ts @@ -6,7 +6,7 @@ import { DISPATCHABLE_EVENT } from '@/types/webhook' import { CopilotAPI } from '@/utils/CopilotAPI' import User from '@api/core/models/User.model' import { TaskTimestamps } from '@api/core/types/tasks' -import { PublicTaskSerializer } from '@api/tasks/public/public.serializer' +import { PublicTaskSerializer, TaskWithWorkflowStateAndAttachments } from '@api/tasks/public/public.serializer' import WorkflowStatesService from '@api/workflow-states/workflowStates.service' import { LogStatus, StateType, Task, WorkflowState } from '@prisma/client' @@ -89,7 +89,7 @@ export const getTaskTimestamps = async ( export const dispatchUpdatedWebhookEvent = async ( user: User, prevTask: Task, - updatedTask: TaskWithWorkflowState, + updatedTask: TaskWithWorkflowStateAndAttachments, isPublicApi: boolean, ): Promise => { let event: DISPATCHABLE_EVENT | undefined @@ -119,7 +119,7 @@ export const dispatchUpdatedWebhookEvent = async ( if (event) { await copilot.dispatchWebhook(event, { workspaceId: user.workspaceId, - payload: PublicTaskSerializer.serialize(updatedTask), + payload: await PublicTaskSerializer.serialize(updatedTask), }) } } diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index 4c1eab127..076e70efc 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -185,7 +185,12 @@ export class TasksService extends TasksSharedService { ...(opts?.manualTimestamp && { createdAt: opts.manualTimestamp }), ...(await getTaskTimestamps('create', this.user, data, undefined, workflowStateStatus)), }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: { + where: { commentId: null, deletedAt: null }, + }, + }, }) console.info('TasksService#createTask | Task created with ID:', newTask.id) @@ -238,7 +243,7 @@ export class TasksService extends TasksSharedService { await Promise.all([ sendTaskCreateNotifications.trigger({ user: this.user, task: newTask }), this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskCreated, { - payload: PublicTaskSerializer.serialize(newTask), + payload: await PublicTaskSerializer.serialize(newTask), workspaceId: this.user.workspaceId, }), ]) @@ -402,7 +407,12 @@ export class TasksService extends TasksSharedService { ...userAssignmentFields, ...(await getTaskTimestamps('update', this.user, data, prevTask)), }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: { + where: { commentId: null, deletedAt: null }, + }, + }, }) subtaskService.setTransaction(tx as PrismaClient) // Archive / unarchive all subtasks if parent task is archived / unarchived @@ -457,7 +467,12 @@ export class TasksService extends TasksSharedService { const deletedTask = await tx.task.update({ where: { id, workspaceId: this.user.workspaceId }, relationLoadStrategy: 'join', - include: { workflowState: true }, + include: { + workflowState: true, + attachments: { + where: { commentId: null, deletedAt: null }, + }, + }, data: { deletedAt: new Date(), deletedBy: deletedBy }, }) await this.setNewLastSubtaskUpdated(task.parentId) //updates lastSubtaskUpdated timestamp of parent task if there is task.parentId @@ -473,7 +488,7 @@ export class TasksService extends TasksSharedService { await Promise.all([ deleteTaskNotifications.trigger({ user: this.user, task }), this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskDeleted, { - payload: PublicTaskSerializer.serialize(updatedTask), + payload: await PublicTaskSerializer.serialize(updatedTask), workspaceId: this.user.workspaceId, }), ]) @@ -602,7 +617,12 @@ export class TasksService extends TasksSharedService { completedByUserType, ...(await getTaskTimestamps('update', this.user, data, prevTask)), }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: { + where: { commentId: null, deletedAt: null }, + }, + }, }) if (updatedTask) { diff --git a/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts b/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts index 2081c63da..eaf9b351e 100644 --- a/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts +++ b/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts @@ -23,7 +23,12 @@ export const queueTaskUpdatedBacklogWebhook = task({ // Extract the latest task data const task = await db.task.findFirst({ where: { id: payload.taskId }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: { + where: { commentId: null, deletedAt: null }, + }, + }, }) if (!task) { throw new Error('Failed to find task for task update backlog webhook') @@ -32,7 +37,7 @@ export const queueTaskUpdatedBacklogWebhook = task({ // Dispatch webhooks const copilot = new CopilotAPI(payload.user.token) await copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskUpdated, { - payload: PublicTaskSerializer.serialize(task), + payload: await PublicTaskSerializer.serialize(task), workspaceId: payload.user.workspaceId, }) From 84ff623511eb46ac88df2150c076a7c7c7595c96 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 19 Jan 2026 16:35:23 +0545 Subject: [PATCH 30/52] chore(OUT-2923): change download url to have null value if the attachment is deleted --- .../api/attachments/public/attachment-public.dto.ts | 3 ++- .../public/attachment-public.serializer.ts | 11 +++++++---- src/app/api/tasks/public/public.service.ts | 8 ++++---- src/app/api/tasks/tasks.service.ts | 8 ++++---- .../queue-task-update-backlog-webhook.ts | 2 +- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/app/api/attachments/public/attachment-public.dto.ts b/src/app/api/attachments/public/attachment-public.dto.ts index 62d9e7905..4903d72a8 100644 --- a/src/app/api/attachments/public/attachment-public.dto.ts +++ b/src/app/api/attachments/public/attachment-public.dto.ts @@ -7,10 +7,11 @@ export const PublicAttachmentDtoSchema = z.object({ fileName: z.string(), fileSize: z.number(), mimeType: z.string(), - downloadUrl: z.string().url(), + downloadUrl: z.string().url().nullable(), uploadedBy: z.string().uuid(), uploadedByUserType: z.nativeEnum(AssigneeType).nullable(), uploadedDate: RFC3339DateSchema, + deletedAt: RFC3339DateSchema.nullable(), }) export type PublicAttachmentDto = z.infer diff --git a/src/app/api/attachments/public/attachment-public.serializer.ts b/src/app/api/attachments/public/attachment-public.serializer.ts index f77e5897f..b8f0ff4a0 100644 --- a/src/app/api/attachments/public/attachment-public.serializer.ts +++ b/src/app/api/attachments/public/attachment-public.serializer.ts @@ -32,13 +32,16 @@ export class PublicAttachmentSerializer { fileName: attachment.fileName, fileSize: attachment.fileSize, mimeType: attachment.fileType, - downloadUrl: z - .string() - .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) - .parse(url), + downloadUrl: attachment.deletedAt + ? null + : z + .string() + .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) + .parse(url), uploadedBy: uploadedBy || attachment.createdById, uploadedByUserType: uploadedByUserType || 'internalUser', // todo: 'internalUser' literal needs to be changed later once uploadedByUserType column is introduced in attachments table uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), + deletedAt: attachment.deletedAt ? RFC3339DateSchema.parse(toRFC3339(attachment.deletedAt)) : null, } }) } diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index f7f79abe0..5dd5d4c57 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -91,7 +91,7 @@ export class PublicTasksService extends TasksSharedService { include: { workflowState: true, attachments: { - where: { commentId: null, deletedAt: null }, + where: { commentId: null }, }, }, }) @@ -113,7 +113,7 @@ export class PublicTasksService extends TasksSharedService { include: { workflowState: true, attachments: { - where: { commentId: null, deletedAt: null }, + where: { commentId: null }, }, }, }) @@ -204,7 +204,7 @@ export class PublicTasksService extends TasksSharedService { include: { workflowState: true, attachments: { - where: { commentId: null, deletedAt: null }, + where: { commentId: null }, }, }, }) @@ -385,7 +385,7 @@ export class PublicTasksService extends TasksSharedService { include: { workflowState: true, attachments: { - where: { commentId: null, deletedAt: null }, + where: { commentId: null }, }, }, }) diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index 076e70efc..ec986d19e 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -188,7 +188,7 @@ export class TasksService extends TasksSharedService { include: { workflowState: true, attachments: { - where: { commentId: null, deletedAt: null }, + where: { commentId: null }, }, }, }) @@ -410,7 +410,7 @@ export class TasksService extends TasksSharedService { include: { workflowState: true, attachments: { - where: { commentId: null, deletedAt: null }, + where: { commentId: null }, }, }, }) @@ -470,7 +470,7 @@ export class TasksService extends TasksSharedService { include: { workflowState: true, attachments: { - where: { commentId: null, deletedAt: null }, + where: { commentId: null }, }, }, data: { deletedAt: new Date(), deletedBy: deletedBy }, @@ -620,7 +620,7 @@ export class TasksService extends TasksSharedService { include: { workflowState: true, attachments: { - where: { commentId: null, deletedAt: null }, + where: { commentId: null }, }, }, }) diff --git a/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts b/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts index eaf9b351e..a8ae35b62 100644 --- a/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts +++ b/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts @@ -26,7 +26,7 @@ export const queueTaskUpdatedBacklogWebhook = task({ include: { workflowState: true, attachments: { - where: { commentId: null, deletedAt: null }, + where: { commentId: null }, }, }, }) From 7b1650e70678306f9142454a9492cb50aa698b1d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 19 Jan 2026 16:38:31 +0545 Subject: [PATCH 31/52] chore(OUT-2923): code cleanup --- src/app/api/tasks/public/public.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index 5dd5d4c57..283b50b6d 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -118,8 +118,6 @@ export class PublicTasksService extends TasksSharedService { }, }) - console.info({ task, attachment: task?.attachments }) - if (!task) throw new APIError(httpStatus.NOT_FOUND, 'The requested task was not found') if (this.user.internalUserId) { await this.checkClientAccessForTask(task, this.user.internalUserId) From 210d8e70aea1874f253337d9189c5b35ef2794bc Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 22 Jan 2026 15:41:12 +0545 Subject: [PATCH 32/52] feat(OUT-2923): delete attachments of task when task is deleted --- src/app/api/attachments/attachments.service.ts | 12 ++++++++++++ ...{attachment-public.dto.ts => public.dto.ts} | 2 +- ...blic.serializer.ts => public.serializer.ts} | 18 ++++++++---------- src/app/api/tasks/public/public.dto.ts | 2 +- src/app/api/tasks/public/public.serializer.ts | 3 ++- src/app/api/tasks/public/public.service.ts | 12 ++++++------ 6 files changed, 30 insertions(+), 19 deletions(-) rename src/app/api/attachments/public/{attachment-public.dto.ts => public.dto.ts} (92%) rename src/app/api/attachments/public/{attachment-public.serializer.ts => public.serializer.ts} (73%) diff --git a/src/app/api/attachments/attachments.service.ts b/src/app/api/attachments/attachments.service.ts index 1583eefdb..cf9857d7d 100644 --- a/src/app/api/attachments/attachments.service.ts +++ b/src/app/api/attachments/attachments.service.ts @@ -113,4 +113,16 @@ export class AttachmentsService extends BaseService { const supabase = new SupabaseService() await supabase.removeAttachmentsFromBucket(filePathArray) } + + async deleteAttachmentsOfTask(taskIds: string[]) { + // Todo: delete attachments from bucket when task is deleted + await this.db.attachment.deleteMany({ + where: { + taskId: { + in: taskIds, + }, + workspaceId: this.user.workspaceId, + }, + }) + } } diff --git a/src/app/api/attachments/public/attachment-public.dto.ts b/src/app/api/attachments/public/public.dto.ts similarity index 92% rename from src/app/api/attachments/public/attachment-public.dto.ts rename to src/app/api/attachments/public/public.dto.ts index 4903d72a8..b94378fe3 100644 --- a/src/app/api/attachments/public/attachment-public.dto.ts +++ b/src/app/api/attachments/public/public.dto.ts @@ -11,7 +11,7 @@ export const PublicAttachmentDtoSchema = z.object({ uploadedBy: z.string().uuid(), uploadedByUserType: z.nativeEnum(AssigneeType).nullable(), uploadedDate: RFC3339DateSchema, - deletedAt: RFC3339DateSchema.nullable(), + deletedDate: RFC3339DateSchema.nullable(), }) export type PublicAttachmentDto = z.infer diff --git a/src/app/api/attachments/public/attachment-public.serializer.ts b/src/app/api/attachments/public/public.serializer.ts similarity index 73% rename from src/app/api/attachments/public/attachment-public.serializer.ts rename to src/app/api/attachments/public/public.serializer.ts index b8f0ff4a0..6da7a8d3f 100644 --- a/src/app/api/attachments/public/attachment-public.serializer.ts +++ b/src/app/api/attachments/public/public.serializer.ts @@ -1,4 +1,4 @@ -import { PublicAttachmentDto } from '@/app/api/attachments/public/attachment-public.dto' +import { PublicAttachmentDto } from '@/app/api/attachments/public/public.dto' import { RFC3339DateSchema } from '@/types/common' import { toRFC3339 } from '@/utils/dateHelper' import { createSignedUrls } from '@/utils/signUrl' @@ -19,7 +19,7 @@ export class PublicAttachmentSerializer { uploadedBy, }: { attachments: Attachment[] - uploadedByUserType?: CommentInitiator | null + uploadedByUserType: CommentInitiator | null uploadedBy?: string }): Promise { const attachmentPaths = attachments.map((attachment) => attachment.filePath) @@ -32,16 +32,14 @@ export class PublicAttachmentSerializer { fileName: attachment.fileName, fileSize: attachment.fileSize, mimeType: attachment.fileType, - downloadUrl: attachment.deletedAt - ? null - : z - .string() - .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) - .parse(url), + downloadUrl: z + .string() + .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) + .parse(url), uploadedBy: uploadedBy || attachment.createdById, - uploadedByUserType: uploadedByUserType || 'internalUser', // todo: 'internalUser' literal needs to be changed later once uploadedByUserType column is introduced in attachments table + uploadedByUserType: uploadedByUserType, uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), - deletedAt: attachment.deletedAt ? RFC3339DateSchema.parse(toRFC3339(attachment.deletedAt)) : null, + deletedDate: attachment.deletedAt ? RFC3339DateSchema.parse(toRFC3339(attachment.deletedAt)) : null, } }) } diff --git a/src/app/api/tasks/public/public.dto.ts b/src/app/api/tasks/public/public.dto.ts index 6886cab1c..e909f25a8 100644 --- a/src/app/api/tasks/public/public.dto.ts +++ b/src/app/api/tasks/public/public.dto.ts @@ -3,7 +3,7 @@ import { CopilotAPI } from '@/utils/CopilotAPI' import { AssigneeType } from '@prisma/client' import { z } from 'zod' import { validateUserIds, ViewersSchema } from '@/types/dto/tasks.dto' -import { PublicAttachmentDtoSchema } from '@/app/api/attachments/public/attachment-public.dto' +import { PublicAttachmentDtoSchema } from '@/app/api/attachments/public/public.dto' export const TaskSourceSchema = z.enum(['web', 'api']) export type TaskSource = z.infer diff --git a/src/app/api/tasks/public/public.serializer.ts b/src/app/api/tasks/public/public.serializer.ts index f1cdcd983..71ee4b950 100644 --- a/src/app/api/tasks/public/public.serializer.ts +++ b/src/app/api/tasks/public/public.serializer.ts @@ -1,4 +1,4 @@ -import { PublicAttachmentSerializer } from '@/app/api/attachments/public/attachment-public.serializer' +import { PublicAttachmentSerializer } from '@/app/api/attachments/public/public.serializer' import APIError from '@/app/api/core/exceptions/api' import DBClient from '@/lib/db' import { RFC3339DateSchema } from '@/types/common' @@ -65,6 +65,7 @@ export class PublicTaskSerializer { viewers: ViewersSchema.parse(task.viewers), attachments: await PublicAttachmentSerializer.serializeAttachments({ attachments: task.attachments, + uploadedByUserType: 'internalUser', // task creator is always IU }), } } diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index 283b50b6d..2fbbd0bf9 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -25,6 +25,7 @@ import { TasksActivityLogger } from '@api/tasks/tasks.logger' import { TemplatesService } from '@api/tasks/templates/templates.service' import { PublicTaskSerializer, TaskWithWorkflowStateAndAttachments } from '@api/tasks/public/public.serializer' import { getBasicPaginationAttributes } from '@/utils/pagination' +import { AttachmentsService } from '@/app/api/attachments/attachments.service' export class PublicTasksService extends TasksSharedService { async getAllTasks(queryFilters: { @@ -434,6 +435,7 @@ export class PublicTasksService extends TasksSharedService { //delete the associated label const labelMappingService = new LabelMappingService(this.user) + // Note: this transaction is timing out in local machine const updatedTask = await this.db.$transaction(async (tx) => { labelMappingService.setTransaction(tx as PrismaClient) await labelMappingService.deleteLabel(task?.label) @@ -451,21 +453,19 @@ export class PublicTasksService extends TasksSharedService { await subtaskService.decreaseSubtaskCount(task.parentId) } await subtaskService.softDeleteAllSubtasks(task.id) - return deletedTask + return { ...deletedTask, attachments: [] } // empty attachments array for deleted tasks }) - // Todo: delete attachments from bucket when task is deleted - const taskWithAttachment = { ...updatedTask, attachments: [] } // empty attachments array for deleted tasks - await Promise.all([ + new AttachmentsService(this.user).deleteAttachmentsOfTask([task.id]), // delete attachments of the task and its subtasks deleteTaskNotifications.trigger({ user: this.user, task }), this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskDeleted, { - payload: await PublicTaskSerializer.serialize(taskWithAttachment), + payload: await PublicTaskSerializer.serialize(updatedTask), workspaceId: this.user.workspaceId, }), ]) - return taskWithAttachment + return updatedTask // Logic to remove internal user notifications when a task is deleted / assignee is deleted // ...In case requirements change later again From 1bb20adbe8add6475e702752c976c630745b244c Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 22 Jan 2026 16:10:41 +0545 Subject: [PATCH 33/52] fix(OUT-2923): return download url null for deleted attachments --- src/app/api/attachments/public/public.serializer.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/app/api/attachments/public/public.serializer.ts b/src/app/api/attachments/public/public.serializer.ts index 6da7a8d3f..7770e4598 100644 --- a/src/app/api/attachments/public/public.serializer.ts +++ b/src/app/api/attachments/public/public.serializer.ts @@ -32,14 +32,16 @@ export class PublicAttachmentSerializer { fileName: attachment.fileName, fileSize: attachment.fileSize, mimeType: attachment.fileType, - downloadUrl: z - .string() - .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) - .parse(url), + downloadUrl: attachment.deletedAt + ? null + : z + .string() + .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) + .parse(url), uploadedBy: uploadedBy || attachment.createdById, uploadedByUserType: uploadedByUserType, uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), - deletedDate: attachment.deletedAt ? RFC3339DateSchema.parse(toRFC3339(attachment.deletedAt)) : null, + deletedDate: toRFC3339(attachment.deletedAt), } }) } From 80c22ba97a0aa9c7e636e2fd1b089a6c7825ba82 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 22 Jan 2026 17:05:18 +0545 Subject: [PATCH 34/52] feat(OUT-2923): filter out attachments that are not available in the content body --- .../attachments/public/public.serializer.ts | 51 +++++++++++-------- src/app/api/tasks/public/public.serializer.ts | 1 + 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/app/api/attachments/public/public.serializer.ts b/src/app/api/attachments/public/public.serializer.ts index 7770e4598..d4a2cfb33 100644 --- a/src/app/api/attachments/public/public.serializer.ts +++ b/src/app/api/attachments/public/public.serializer.ts @@ -16,34 +16,45 @@ export class PublicAttachmentSerializer { static async serializeAttachments({ attachments, uploadedByUserType, + content, uploadedBy, }: { attachments: Attachment[] uploadedByUserType: CommentInitiator | null + content: string | null uploadedBy?: string }): Promise { - const attachmentPaths = attachments.map((attachment) => attachment.filePath) + // check if attachments are in the content. If yes + const attachmentPaths = attachments + .map((attachment) => { + return attachment.filePath + }) + .filter((path) => content?.includes(path)) + const signedUrls = await PublicAttachmentSerializer.getFormattedSignedUrls(attachmentPaths) - return attachments.map((attachment) => { - const url = signedUrls.find((item) => item.path === attachment.filePath)?.url - return { - id: attachment.id, - fileName: attachment.fileName, - fileSize: attachment.fileSize, - mimeType: attachment.fileType, - downloadUrl: attachment.deletedAt - ? null - : z - .string() - .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) - .parse(url), - uploadedBy: uploadedBy || attachment.createdById, - uploadedByUserType: uploadedByUserType, - uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), - deletedDate: toRFC3339(attachment.deletedAt), - } - }) + return attachments + .map((attachment) => { + const url = signedUrls.find((item) => item.path === attachment.filePath)?.url + if (!url) return null + return { + id: attachment.id, + fileName: attachment.fileName, + fileSize: attachment.fileSize, + mimeType: attachment.fileType, + downloadUrl: attachment.deletedAt + ? null + : z + .string() + .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) + .parse(url), + uploadedBy: uploadedBy || attachment.createdById, + uploadedByUserType: uploadedByUserType, + uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), + deletedDate: toRFC3339(attachment.deletedAt), + } + }) + .filter((attachment) => attachment !== null) } static async getFormattedSignedUrls(attachmentPaths: string[]) { diff --git a/src/app/api/tasks/public/public.serializer.ts b/src/app/api/tasks/public/public.serializer.ts index 71ee4b950..931aff3cf 100644 --- a/src/app/api/tasks/public/public.serializer.ts +++ b/src/app/api/tasks/public/public.serializer.ts @@ -66,6 +66,7 @@ export class PublicTaskSerializer { attachments: await PublicAttachmentSerializer.serializeAttachments({ attachments: task.attachments, uploadedByUserType: 'internalUser', // task creator is always IU + content: task.body, }), } } From f98308e98bd872e866514c4ea83ff255d3dba013 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 12:20:47 +0545 Subject: [PATCH 35/52] feat(OUT-2923): remove commentId null condition --- src/app/api/tasks/public/public.service.ts | 16 ++++------------ src/app/api/tasks/tasks.service.ts | 16 ++++------------ .../queue-task-update-backlog-webhook.ts | 4 +--- 3 files changed, 9 insertions(+), 27 deletions(-) diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index 2fbbd0bf9..1edbf3835 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -91,9 +91,7 @@ export class PublicTasksService extends TasksSharedService { relationLoadStrategy: 'join', include: { workflowState: true, - attachments: { - where: { commentId: null }, - }, + attachments: true, }, }) @@ -113,9 +111,7 @@ export class PublicTasksService extends TasksSharedService { relationLoadStrategy: 'join', include: { workflowState: true, - attachments: { - where: { commentId: null }, - }, + attachments: true, }, }) @@ -202,9 +198,7 @@ export class PublicTasksService extends TasksSharedService { }, include: { workflowState: true, - attachments: { - where: { commentId: null }, - }, + attachments: true, }, }) console.info('PublicTasksService#createTask | Task created with ID:', newTask.id) @@ -383,9 +377,7 @@ export class PublicTasksService extends TasksSharedService { }, include: { workflowState: true, - attachments: { - where: { commentId: null }, - }, + attachments: true, }, }) subtaskService.setTransaction(tx as PrismaClient) diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index ec986d19e..af8b9a405 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -187,9 +187,7 @@ export class TasksService extends TasksSharedService { }, include: { workflowState: true, - attachments: { - where: { commentId: null }, - }, + attachments: true, }, }) console.info('TasksService#createTask | Task created with ID:', newTask.id) @@ -409,9 +407,7 @@ export class TasksService extends TasksSharedService { }, include: { workflowState: true, - attachments: { - where: { commentId: null }, - }, + attachments: true, }, }) subtaskService.setTransaction(tx as PrismaClient) @@ -469,9 +465,7 @@ export class TasksService extends TasksSharedService { relationLoadStrategy: 'join', include: { workflowState: true, - attachments: { - where: { commentId: null }, - }, + attachments: true, }, data: { deletedAt: new Date(), deletedBy: deletedBy }, }) @@ -619,9 +613,7 @@ export class TasksService extends TasksSharedService { }, include: { workflowState: true, - attachments: { - where: { commentId: null }, - }, + attachments: true, }, }) diff --git a/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts b/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts index a8ae35b62..d43cfadb0 100644 --- a/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts +++ b/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts @@ -25,9 +25,7 @@ export const queueTaskUpdatedBacklogWebhook = task({ where: { id: payload.taskId }, include: { workflowState: true, - attachments: { - where: { commentId: null }, - }, + attachments: true, }, }) if (!task) { From 76e12df126110313da03238f19d9ff71393bf597 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 12:30:57 +0545 Subject: [PATCH 36/52] refactor(OUT-2923): rename classes, remove functions with same functionality --- src/app/api/comment/comment.service.ts | 2 +- .../public/comment-public.serializer.ts | 84 ------------------- ...lic.controller.ts => public.controller.ts} | 3 +- .../{comment-public.dto.ts => public.dto.ts} | 14 +--- .../api/comment/public/public.serializer.ts | 38 +++++++++ .../public/[id]/comments/[commentId]/route.ts | 2 +- .../api/tasks/public/[id]/comments/route.ts | 2 +- 7 files changed, 43 insertions(+), 102 deletions(-) delete mode 100644 src/app/api/comment/public/comment-public.serializer.ts rename src/app/api/comment/public/{comment-public.controller.ts => public.controller.ts} (97%) rename src/app/api/comment/public/{comment-public.dto.ts => public.dto.ts} (59%) create mode 100644 src/app/api/comment/public/public.serializer.ts diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 506c6393d..da72b7318 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -1,5 +1,5 @@ import { AttachmentsService } from '@/app/api/attachments/attachments.service' -import { PublicCommentSerializer } from '@/app/api/comment/public/comment-public.serializer' +import { PublicCommentSerializer } from '@/app/api/comment/public/public.serializer' import { sendCommentCreateNotifications } from '@/jobs/notifications' import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' import { InitiatedEntity } from '@/types/common' diff --git a/src/app/api/comment/public/comment-public.serializer.ts b/src/app/api/comment/public/comment-public.serializer.ts deleted file mode 100644 index 6dd19c2eb..000000000 --- a/src/app/api/comment/public/comment-public.serializer.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { PublicAttachmentDto, PublicCommentDto, PublicCommentDtoSchema } from '@/app/api/comment/public/comment-public.dto' -import { RFC3339DateSchema } from '@/types/common' -import { CommentWithAttachments } from '@/types/dto/comment.dto' -import { toRFC3339 } from '@/utils/dateHelper' -import { createSignedUrls } from '@/utils/signUrl' -import { Attachment, CommentInitiator } from '@prisma/client' -import { z } from 'zod' - -export class PublicCommentSerializer { - static async serializeUnsafe(comment: CommentWithAttachments): Promise { - return { - id: comment.id, - object: 'taskComment', - parentCommentId: comment.parentId, - taskId: comment.taskId, - content: comment.content, - createdBy: comment.initiatorId, - createdByUserType: comment.initiatorType, - createdDate: RFC3339DateSchema.parse(toRFC3339(comment.createdAt)), - updatedDate: RFC3339DateSchema.parse(toRFC3339(comment.updatedAt)), - deletedDate: toRFC3339(comment.deletedAt), - attachments: await PublicCommentSerializer.serializeAttachments({ - attachments: comment.attachments, - uploadedByUserType: comment.initiatorType, - uploadedBy: comment.initiatorId, - }), - } - } - - /** - * - * @param attachments array of Attachment - * @param uploadedBy id of the one who commented - * @param uploadedByUserType usertype of the one who commented - * @returns Array of PublicAttachmentDto - */ - static async serializeAttachments({ - attachments, - uploadedByUserType, - uploadedBy, - }: { - attachments: Attachment[] - uploadedByUserType: CommentInitiator | null - uploadedBy: string - }): Promise { - const attachmentPaths = attachments.map((attachment) => attachment.filePath) - const signedUrls = await PublicCommentSerializer.getFormattedSignedUrls(attachmentPaths) - - return attachments.map((attachment) => { - const url = signedUrls.find((item) => item.path === attachment.filePath)?.url - return { - id: attachment.id, - fileName: attachment.fileName, - fileSize: attachment.fileSize, - mimeType: attachment.fileType, - downloadUrl: attachment.deletedAt - ? null - : z - .string() - .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) - .parse(url), - uploadedBy, - uploadedByUserType, - uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), - deletedDate: toRFC3339(attachment.deletedAt), - } - }) - } - - static async serialize(comment: CommentWithAttachments): Promise { - return PublicCommentDtoSchema.parse(await PublicCommentSerializer.serializeUnsafe(comment)) - } - - static async serializeMany(comments: CommentWithAttachments[]): Promise { - const serializedComments = await Promise.all(comments.map(async (comment) => PublicCommentSerializer.serialize(comment))) - return z.array(PublicCommentDtoSchema).parse(serializedComments) - } - - static async getFormattedSignedUrls(attachmentPaths: string[]) { - if (!attachmentPaths.length) return [] - const signedUrls = await createSignedUrls(attachmentPaths) - return signedUrls.map((item) => ({ path: item.path, url: item.signedUrl })) - } -} diff --git a/src/app/api/comment/public/comment-public.controller.ts b/src/app/api/comment/public/public.controller.ts similarity index 97% rename from src/app/api/comment/public/comment-public.controller.ts rename to src/app/api/comment/public/public.controller.ts index 774e9e3f9..af210e5b4 100644 --- a/src/app/api/comment/public/comment-public.controller.ts +++ b/src/app/api/comment/public/public.controller.ts @@ -1,10 +1,9 @@ import { CommentService } from '@/app/api/comment/comment.service' import authenticate from '@/app/api/core/utils/authenticate' -import { defaultLimit } from '@/constants/public-api' import { getSearchParams } from '@/utils/request' import { NextRequest, NextResponse } from 'next/server' import { decode, encode } from 'js-base64' -import { PublicCommentSerializer } from '@/app/api/comment/public/comment-public.serializer' +import { PublicCommentSerializer } from '@/app/api/comment/public/public.serializer' import { CommentsPublicFilterType } from '@/types/dto/comment.dto' import { IdParams } from '@/app/api/core/types/api' import { getPaginationLimit } from '@/utils/pagination' diff --git a/src/app/api/comment/public/comment-public.dto.ts b/src/app/api/comment/public/public.dto.ts similarity index 59% rename from src/app/api/comment/public/comment-public.dto.ts rename to src/app/api/comment/public/public.dto.ts index 9fa643265..f1ed9495f 100644 --- a/src/app/api/comment/public/comment-public.dto.ts +++ b/src/app/api/comment/public/public.dto.ts @@ -1,20 +1,8 @@ +import { PublicAttachmentDtoSchema } from '@/app/api/attachments/public/public.dto' import { RFC3339DateSchema } from '@/types/common' import { AssigneeType } from '@prisma/client' import z from 'zod' -export const PublicAttachmentDtoSchema = z.object({ - id: z.string().uuid(), - fileName: z.string(), - fileSize: z.number(), - mimeType: z.string(), - downloadUrl: z.string().url().nullable(), - uploadedBy: z.string().uuid(), - uploadedByUserType: z.nativeEnum(AssigneeType).nullable(), - uploadedDate: RFC3339DateSchema, - deletedDate: RFC3339DateSchema.nullable(), -}) -export type PublicAttachmentDto = z.infer - export const PublicCommentDtoSchema = z.object({ id: z.string().uuid(), object: z.literal('taskComment'), diff --git a/src/app/api/comment/public/public.serializer.ts b/src/app/api/comment/public/public.serializer.ts new file mode 100644 index 000000000..85815a784 --- /dev/null +++ b/src/app/api/comment/public/public.serializer.ts @@ -0,0 +1,38 @@ +import { PublicAttachmentSerializer } from '@/app/api/attachments/public/public.serializer' +import { PublicCommentDto, PublicCommentDtoSchema } from '@/app/api/comment/public/public.dto' +import { RFC3339DateSchema } from '@/types/common' +import { CommentWithAttachments } from '@/types/dto/comment.dto' +import { toRFC3339 } from '@/utils/dateHelper' +import { z } from 'zod' + +export class PublicCommentSerializer { + static async serializeUnsafe(comment: CommentWithAttachments): Promise { + return { + id: comment.id, + object: 'taskComment', + parentCommentId: comment.parentId, + taskId: comment.taskId, + content: comment.content, + createdBy: comment.initiatorId, + createdByUserType: comment.initiatorType, + createdDate: RFC3339DateSchema.parse(toRFC3339(comment.createdAt)), + updatedDate: RFC3339DateSchema.parse(toRFC3339(comment.updatedAt)), + deletedDate: toRFC3339(comment.deletedAt), + attachments: await PublicAttachmentSerializer.serializeAttachments({ + attachments: comment.attachments, + uploadedByUserType: comment.initiatorType, + uploadedBy: comment.initiatorId, + content: comment.content, + }), + } + } + + static async serialize(comment: CommentWithAttachments): Promise { + return PublicCommentDtoSchema.parse(await PublicCommentSerializer.serializeUnsafe(comment)) + } + + static async serializeMany(comments: CommentWithAttachments[]): Promise { + const serializedComments = await Promise.all(comments.map(async (comment) => PublicCommentSerializer.serialize(comment))) + return z.array(PublicCommentDtoSchema).parse(serializedComments) + } +} diff --git a/src/app/api/tasks/public/[id]/comments/[commentId]/route.ts b/src/app/api/tasks/public/[id]/comments/[commentId]/route.ts index 228213ee3..56b526778 100644 --- a/src/app/api/tasks/public/[id]/comments/[commentId]/route.ts +++ b/src/app/api/tasks/public/[id]/comments/[commentId]/route.ts @@ -1,4 +1,4 @@ -import { deleteOneCommentPublic, getOneCommentPublic } from '@/app/api/comment/public/comment-public.controller' +import { deleteOneCommentPublic, getOneCommentPublic } from '@/app/api/comment/public/public.controller' import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' export const GET = withErrorHandler(getOneCommentPublic) diff --git a/src/app/api/tasks/public/[id]/comments/route.ts b/src/app/api/tasks/public/[id]/comments/route.ts index 5b52b0b42..bdf711a4b 100644 --- a/src/app/api/tasks/public/[id]/comments/route.ts +++ b/src/app/api/tasks/public/[id]/comments/route.ts @@ -1,4 +1,4 @@ -import { getAllCommentsPublic } from '@/app/api/comment/public/comment-public.controller' +import { getAllCommentsPublic } from '@/app/api/comment/public/public.controller' import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' export const GET = withErrorHandler(getAllCommentsPublic) From c2d8e08c5cde87fa944af4c5bb09c8b77045cf6f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 13:51:16 +0545 Subject: [PATCH 37/52] feat(OUT-2923): remove attachments from the bucket when a task is deleted --- .../api/attachments/attachments.service.ts | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/app/api/attachments/attachments.service.ts b/src/app/api/attachments/attachments.service.ts index cf9857d7d..cc842740b 100644 --- a/src/app/api/attachments/attachments.service.ts +++ b/src/app/api/attachments/attachments.service.ts @@ -93,17 +93,15 @@ export class AttachmentsService extends BaseService { policyGate.authorize(UserAction.Delete, Resource.Attachments) const commentAttachment = await this.db.$transaction(async (tx) => { - this.setTransaction(tx as PrismaClient) - - const commentAttachment = await this.db.attachment.findMany({ + const commentAttachment = await tx.attachment.findMany({ where: { commentId: commentId, workspaceId: this.user.workspaceId }, + select: { filePath: true }, }) - await this.db.attachment.deleteMany({ + await tx.attachment.deleteMany({ where: { commentId: commentId, workspaceId: this.user.workspaceId }, }) - this.unsetTransaction() return commentAttachment }) @@ -115,14 +113,33 @@ export class AttachmentsService extends BaseService { } async deleteAttachmentsOfTask(taskIds: string[]) { - // Todo: delete attachments from bucket when task is deleted - await this.db.attachment.deleteMany({ - where: { - taskId: { - in: taskIds, + const taskAttachment = await this.db.$transaction(async (tx) => { + const taskAttachment = await tx.attachment.findMany({ + where: { + taskId: { + in: taskIds, + }, + workspaceId: this.user.workspaceId, }, - workspaceId: this.user.workspaceId, - }, + select: { filePath: true }, + }) + + await tx.attachment.deleteMany({ + where: { + taskId: { + in: taskIds, + }, + workspaceId: this.user.workspaceId, + }, + }) + + return taskAttachment }) + + // directly delete attachments from bucket when deleting comments. + // Postgres transaction is not valid for supabase object so placing it after record deletion from db + const filePathArray = taskAttachment.map((el) => el.filePath) + const supabase = new SupabaseService() + await supabase.removeAttachmentsFromBucket(filePathArray) } } From 164111465f25a8a60490c8b9cb4fc12d73e7734e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 14:22:46 +0545 Subject: [PATCH 38/52] chore(OUT-2923): remove deletedDate attribute from the attachment response --- src/app/api/attachments/public/public.dto.ts | 1 - src/app/api/attachments/public/public.serializer.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/app/api/attachments/public/public.dto.ts b/src/app/api/attachments/public/public.dto.ts index b94378fe3..4b960a717 100644 --- a/src/app/api/attachments/public/public.dto.ts +++ b/src/app/api/attachments/public/public.dto.ts @@ -11,7 +11,6 @@ export const PublicAttachmentDtoSchema = z.object({ uploadedBy: z.string().uuid(), uploadedByUserType: z.nativeEnum(AssigneeType).nullable(), uploadedDate: RFC3339DateSchema, - deletedDate: RFC3339DateSchema.nullable(), }) export type PublicAttachmentDto = z.infer diff --git a/src/app/api/attachments/public/public.serializer.ts b/src/app/api/attachments/public/public.serializer.ts index d4a2cfb33..24423d627 100644 --- a/src/app/api/attachments/public/public.serializer.ts +++ b/src/app/api/attachments/public/public.serializer.ts @@ -51,7 +51,6 @@ export class PublicAttachmentSerializer { uploadedBy: uploadedBy || attachment.createdById, uploadedByUserType: uploadedByUserType, uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), - deletedDate: toRFC3339(attachment.deletedAt), } }) .filter((attachment) => attachment !== null) From 364b0f5e05dd61eca1314c431daccd6a9c66c2ef Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 15:50:22 +0545 Subject: [PATCH 39/52] fix(OUT-2961): include CU to create attachments --- src/app/api/attachments/attachments.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/api/attachments/attachments.service.ts b/src/app/api/attachments/attachments.service.ts index cc842740b..455dfb839 100644 --- a/src/app/api/attachments/attachments.service.ts +++ b/src/app/api/attachments/attachments.service.ts @@ -31,7 +31,7 @@ export class AttachmentsService extends BaseService { const newAttachment = await this.db.attachment.create({ data: { ...data, - createdById: z.string().parse(this.user.internalUserId), + createdById: z.string().parse(this.user.internalUserId || this.user.clientId), // CU are also allowed to create attachments workspaceId: this.user.workspaceId, }, }) @@ -41,7 +41,7 @@ export class AttachmentsService extends BaseService { async createMultipleAttachments(data: CreateAttachmentRequest[]) { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Create, Resource.Attachments) - const userId = z.string().parse(this.user.internalUserId) + // TODO: @arpandhakal - $transaction here could consume a lot of sequential db connections, better to use Promise.all // and reuse active connections instead. const newAttachments = await this.db.$transaction(async (prisma) => { @@ -49,7 +49,7 @@ export class AttachmentsService extends BaseService { prisma.attachment.create({ data: { ...attachmentData, - createdById: userId, + createdById: z.string().parse(this.user.internalUserId || this.user.clientId), // CU are also allowed to create attachments workspaceId: this.user.workspaceId, }, }), From f47d980e5c21077be5325792a38d98979a5195ad Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 15:52:34 +0545 Subject: [PATCH 40/52] fix(OUT-2961): dispatch comment.created webhook with signed attachments --- src/app/api/comment/comment.service.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index da72b7318..b44145662 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -55,15 +55,18 @@ export class CommentService extends BaseService { include: { attachments: true }, }) + let commentToReturn = comment // return the latest comment object with attachments (if any) try { if (comment.content) { const newContent = await this.updateCommentIdOfAttachmentsAfterCreation(comment.content, data.taskId, comment.id) - await this.db.comment.update({ + // mutate commentToReturn here with signed attachment urls + commentToReturn = await this.db.comment.update({ where: { id: comment.id }, data: { content: newContent, updatedAt: comment.createdAt, //dont updated the updatedAt, because it will show (edited) for recently created comments. }, + include: { attachments: true }, }) console.info('CommentService#createComment | Comment content attachments updated for comment ID:', comment.id) } @@ -96,11 +99,11 @@ export class CommentService extends BaseService { // dispatch a webhook event when comment is created await this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.CommentCreated, { - payload: await PublicCommentSerializer.serialize(comment), + payload: await PublicCommentSerializer.serialize(commentToReturn), workspaceId: this.user.workspaceId, }) - return comment + return commentToReturn // if (data.mentions) { // await notificationService.createBulkNotification(NotificationTaskActions.Mentioned, task, data.mentions, { From 68d5adb2d78aaafa92416acb2b38a360f7ae9ac5 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 27 Jan 2026 15:08:57 +0545 Subject: [PATCH 41/52] feat(OUT-3009): removed the requirement of taskId in comment endpoints - All the comments endpoint, list comments, retrieve comments and deleted comments paths have been changed not requiring task/{taskId} anymore. - The new comments public url goes something like : {{url}}/api/comment/public?token for list and {{url}}/api/comment/public/{{id}}?token for retrieve and delete. - Added a security mechasim of checking task access scope directly in comments.findMany() filters instead of traversing comments. --- src/app/api/comment/comment.service.ts | 54 ++++++++++++++++++- .../public/[id]}/route.ts | 0 .../api/comment/public/public.controller.ts | 37 ++++++------- .../[id]/comments => comment/public}/route.ts | 0 src/types/dto/comment.dto.ts | 2 +- 5 files changed, 72 insertions(+), 21 deletions(-) rename src/app/api/{tasks/public/[id]/comments/[commentId] => comment/public/[id]}/route.ts (100%) rename src/app/api/{tasks/public/[id]/comments => comment/public}/route.ts (100%) diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index b44145662..f4b66bfbc 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -404,7 +404,7 @@ export class CommentService extends BaseService { async getAllComments(queryFilters: CommentsPublicFilterType): Promise { const { parentId, taskId, limit, lastIdCursor, initiatorId } = queryFilters - const where = { + const where: Prisma.CommentWhereInput = { parentId, taskId, initiatorId, @@ -412,6 +412,9 @@ export class CommentService extends BaseService { } const pagination = getBasicPaginationAttributes(limit, lastIdCursor) + if (this.user.clientId || this.user.companyId) { + where.task = this.getClientOrCompanyAssigneeFilter() + } return await this.db.comment.findMany({ where, @@ -422,8 +425,15 @@ export class CommentService extends BaseService { } async hasMoreCommentsAfterCursor(id: string, publicFilters: Partial): Promise { + const where: Prisma.CommentWhereInput = { + ...publicFilters, + workspaceId: this.user.workspaceId, + } + if (this.user.clientId || this.user.companyId) { + where.task = this.getClientOrCompanyAssigneeFilter() + } const newComment = await this.db.comment.findFirst({ - where: { ...publicFilters, workspaceId: this.user.workspaceId }, + where, cursor: { id }, skip: 1, orderBy: { createdAt: 'desc' }, @@ -452,4 +462,44 @@ export class CommentService extends BaseService { throw err } } + + protected getClientOrCompanyAssigneeFilter(includeViewer: boolean = true): Prisma.TaskWhereInput { + const clientId = z.string().uuid().safeParse(this.user.clientId).data + const companyId = z.string().uuid().parse(this.user.companyId) + + const filters = [] + + if (clientId && companyId) { + filters.push( + // Get client tasks for the particular companyId + { clientId, companyId }, + // Get company tasks for the client's companyId + { companyId, clientId: null }, + ) + if (includeViewer) + filters.push( + // Get tasks that includes the client as a viewer + { + viewers: { + hasSome: [{ clientId, companyId }, { companyId }], + }, + }, + ) + } else if (companyId) { + filters.push( + // Get only company tasks for the client's companyId + { clientId: null, companyId }, + ) + if (includeViewer) + filters.push( + // Get tasks that includes the company as a viewer + { + viewers: { + hasSome: [{ companyId }], + }, + }, + ) + } + return filters.length > 0 ? { OR: filters } : {} + } //Repeated twice because taskSharedService is an abstract class. } diff --git a/src/app/api/tasks/public/[id]/comments/[commentId]/route.ts b/src/app/api/comment/public/[id]/route.ts similarity index 100% rename from src/app/api/tasks/public/[id]/comments/[commentId]/route.ts rename to src/app/api/comment/public/[id]/route.ts diff --git a/src/app/api/comment/public/public.controller.ts b/src/app/api/comment/public/public.controller.ts index af210e5b4..19efc7171 100644 --- a/src/app/api/comment/public/public.controller.ts +++ b/src/app/api/comment/public/public.controller.ts @@ -1,36 +1,35 @@ import { CommentService } from '@/app/api/comment/comment.service' -import authenticate from '@/app/api/core/utils/authenticate' -import { getSearchParams } from '@/utils/request' -import { NextRequest, NextResponse } from 'next/server' -import { decode, encode } from 'js-base64' import { PublicCommentSerializer } from '@/app/api/comment/public/public.serializer' +import authenticate from '@/app/api/core/utils/authenticate' import { CommentsPublicFilterType } from '@/types/dto/comment.dto' -import { IdParams } from '@/app/api/core/types/api' import { getPaginationLimit } from '@/utils/pagination' +import { getSearchParams } from '@/utils/request' +import { decode, encode } from 'js-base64' +import { NextRequest, NextResponse } from 'next/server' type TaskAndCommentIdParams = { - params: Promise<{ id: string; commentId: string }> + params: Promise<{ id: string }> } -export const getAllCommentsPublic = async (req: NextRequest, { params }: IdParams) => { - const { id: taskId } = await params +export const getAllCommentsPublic = async (req: NextRequest) => { const user = await authenticate(req) - const { parentCommentId, createdBy, limit, nextToken } = getSearchParams(req.nextUrl.searchParams, [ + const { parentCommentId, createdBy, limit, nextToken, taskId } = getSearchParams(req.nextUrl.searchParams, [ 'parentCommentId', 'createdBy', 'limit', 'nextToken', + 'taskId', ]) const publicFilters: CommentsPublicFilterType = { - taskId, + taskId: taskId || undefined, parentId: parentCommentId || undefined, initiatorId: createdBy || undefined, } const commentService = new CommentService(user) - await commentService.checkCommentTaskPermissionForUser(taskId) // check the user accessing the comment has access to the task + taskId && (await commentService.checkCommentTaskPermissionForUser(taskId)) // check the user accessing the comment has access to the task const comments = await commentService.getAllComments({ limit: getPaginationLimit(limit), @@ -51,25 +50,27 @@ export const getAllCommentsPublic = async (req: NextRequest, { params }: IdParam } export const getOneCommentPublic = async (req: NextRequest, { params }: TaskAndCommentIdParams) => { - const { id: taskId, commentId } = await params + const { id } = await params const user = await authenticate(req) const commentService = new CommentService(user) - await commentService.checkCommentTaskPermissionForUser(taskId) // check the user accessing the comment has access to the task - - const comment = await commentService.getCommentById({ id: commentId, includeAttachments: true }) + const comment = await commentService.getCommentById({ id, includeAttachments: true }) if (!comment) return NextResponse.json({ data: null }) + await commentService.checkCommentTaskPermissionForUser(comment.taskId) // check the user accessing the comment has access to the task + return NextResponse.json({ data: await PublicCommentSerializer.serialize(comment) }) } export const deleteOneCommentPublic = async (req: NextRequest, { params }: TaskAndCommentIdParams) => { - const { id: taskId, commentId } = await params + const { id } = await params const user = await authenticate(req) const commentService = new CommentService(user) - await commentService.checkCommentTaskPermissionForUser(taskId) // check the user accessing the comment has access to the task - const deletedComment = await commentService.delete(commentId) + const deletedComment = await commentService.delete(id) + + await commentService.checkCommentTaskPermissionForUser(deletedComment.taskId) // check the user accessing the comment has access to the task + return NextResponse.json({ ...(await PublicCommentSerializer.serialize(deletedComment)) }) } diff --git a/src/app/api/tasks/public/[id]/comments/route.ts b/src/app/api/comment/public/route.ts similarity index 100% rename from src/app/api/tasks/public/[id]/comments/route.ts rename to src/app/api/comment/public/route.ts diff --git a/src/types/dto/comment.dto.ts b/src/types/dto/comment.dto.ts index 326a74a41..d81b2976b 100644 --- a/src/types/dto/comment.dto.ts +++ b/src/types/dto/comment.dto.ts @@ -42,7 +42,7 @@ export type CommentResponse = z.infer export type CommentWithAttachments = Comment & { attachments: Attachment[] } export type CommentsPublicFilterType = { - taskId: string + taskId?: string parentId?: string initiatorId?: string limit?: number From c861459c9b800d13cdccd0d71abf80bd93fbf2fd Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 27 Jan 2026 16:03:00 +0545 Subject: [PATCH 42/52] fix(OUT-3009): applied requested changes, changed api route from api/comment to api/comments --- .../services/activity-log.service.ts | 2 +- src/app/api/comment/[id]/route.ts | 5 - src/app/api/comment/comment.controller.ts | 54 -- src/app/api/comment/comment.repository.ts | 57 -- src/app/api/comment/comment.service.ts | 505 ------------------ src/app/api/comment/public/[id]/route.ts | 5 - .../api/comment/public/public.controller.ts | 76 --- src/app/api/comment/public/public.dto.ts | 19 - .../api/comment/public/public.serializer.ts | 38 -- src/app/api/comment/public/route.ts | 4 - src/app/api/comment/route.ts | 7 - .../detail/[task_id]/[user_type]/actions.ts | 6 +- src/components/cards/CommentCard.tsx | 2 +- .../send-reply-create-notifications.ts | 4 +- 14 files changed, 7 insertions(+), 777 deletions(-) delete mode 100755 src/app/api/comment/[id]/route.ts delete mode 100755 src/app/api/comment/comment.controller.ts delete mode 100644 src/app/api/comment/comment.repository.ts delete mode 100755 src/app/api/comment/comment.service.ts delete mode 100644 src/app/api/comment/public/[id]/route.ts delete mode 100644 src/app/api/comment/public/public.controller.ts delete mode 100644 src/app/api/comment/public/public.dto.ts delete mode 100644 src/app/api/comment/public/public.serializer.ts delete mode 100644 src/app/api/comment/public/route.ts delete mode 100755 src/app/api/comment/route.ts diff --git a/src/app/api/activity-logs/services/activity-log.service.ts b/src/app/api/activity-logs/services/activity-log.service.ts index a5faa4fb6..c83cd6cec 100644 --- a/src/app/api/activity-logs/services/activity-log.service.ts +++ b/src/app/api/activity-logs/services/activity-log.service.ts @@ -9,7 +9,7 @@ import { SchemaByActivityType, } from '@api/activity-logs/const' import { LogResponse, LogResponseSchema } from '@api/activity-logs/schemas/LogResponseSchema' -import { CommentService } from '@api/comment/comment.service' +import { CommentService } from '@/app/api/comments/comment.service' import APIError from '@api/core/exceptions/api' import User from '@api/core/models/User.model' import { BaseService } from '@api/core/services/base.service' diff --git a/src/app/api/comment/[id]/route.ts b/src/app/api/comment/[id]/route.ts deleted file mode 100755 index 35f6779aa..000000000 --- a/src/app/api/comment/[id]/route.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { withErrorHandler } from '@api/core/utils/withErrorHandler' -import { deleteComment, updateComment } from '@api/comment/comment.controller' - -export const PATCH = withErrorHandler(updateComment) -export const DELETE = withErrorHandler(deleteComment) diff --git a/src/app/api/comment/comment.controller.ts b/src/app/api/comment/comment.controller.ts deleted file mode 100755 index ea234fe52..000000000 --- a/src/app/api/comment/comment.controller.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { CreateCommentSchema, UpdateCommentSchema } from '@/types/dto/comment.dto' -import { getSearchParams } from '@/utils/request' -import { signMediaForComments } from '@/utils/signedUrlReplacer' -import { CommentService } from '@api/comment/comment.service' -import { IdParams } from '@api/core/types/api' -import authenticate from '@api/core/utils/authenticate' -import httpStatus from 'http-status' -import { NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' - -export const createComment = async (req: NextRequest) => { - const user = await authenticate(req) - - const commentService = new CommentService(user) - const data = CreateCommentSchema.parse(await req.json()) - const comment = await commentService.create(data) - return NextResponse.json({ comment }, { status: httpStatus.CREATED }) -} - -export const deleteComment = async (req: NextRequest, { params }: IdParams) => { - const { id } = await params - const user = await authenticate(req) - - const commentService = new CommentService(user) - await commentService.delete(id) - //Can't use status code 204 in NextResponse as of now - https://github.com/vercel/next.js/discussions/51475 - //Using Response is also not allowed since withErrorHandler wrapper uses NextResponse. - return NextResponse.json({ message: 'Comment deleted!' }) -} - -export const updateComment = async (req: NextRequest, { params }: IdParams) => { - const { id } = await params - const user = await authenticate(req) - - const data = UpdateCommentSchema.parse(await req.json()) - const commentService = new CommentService(user) - const comment = await commentService.update(id, data) - - return NextResponse.json({ comment }) -} - -export const getFilteredComments = async (req: NextRequest) => { - const user = await authenticate(req) - - const { parentId: rawParentId } = getSearchParams(req.nextUrl.searchParams, ['parentId']) - const parentId = z.string().uuid().parse(rawParentId) - const commentService = new CommentService(user) - const comments = await commentService.getComments({ parentId }) - const signedComments = await signMediaForComments(comments) - - return NextResponse.json({ - comments: await commentService.addInitiatorDetails(signedComments), - }) -} diff --git a/src/app/api/comment/comment.repository.ts b/src/app/api/comment/comment.repository.ts deleted file mode 100644 index 104ffe979..000000000 --- a/src/app/api/comment/comment.repository.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { BaseRepository } from '@api/core/repository/base.repository' -import { Comment, CommentInitiator, Prisma } from '@prisma/client' - -type CommentInitiatorResult = { parentId: string; initiatorId: string; initiatorType: CommentInitiator } - -export class CommentRepository extends BaseRepository { - async getFirstCommentInitiators( - parentCommentIds: string[], - limitPerParent: number = 3, - ): Promise { - const results = await this.db.$queryRaw` - WITH ranked_comments AS ( - SELECT "parentId", "initiatorId", "initiatorType", - -- Use DENSE_RANK to ensure ranking is based on earliest time based on createdAt - DENSE_RANK() OVER ( - PARTITION BY "parentId" ORDER BY MIN("createdAt") ASC - ) AS rank_num - FROM "Comments" - WHERE "parentId"::text IN (${Prisma.join(parentCommentIds)}) - AND "deletedAt" IS NULL - -- Ensures one initiatorId appears only ONCE per parentId (hopefully) - GROUP BY "parentId", "initiatorId", "initiatorType" - ) - SELECT "parentId", "initiatorId", "initiatorType" - FROM ranked_comments - WHERE rank_num <= ${limitPerParent}; - ` - return results - } - - async getAllRepliesForParents(parentCommentIds: string[]): Promise { - return await this.db.comment.findMany({ - where: { - parentId: { in: parentCommentIds }, - workspaceId: this.user.workspaceId, - }, - orderBy: { createdAt: 'desc' }, - }) - } - - async getLimitedRepliesForParents(parentCommentIds: string[], limitPerParent: number = 3): Promise { - // IMPORTANT: If you change the schema of Comments table be sure to add them here too. - return await this.db.$queryRaw` - WITH replies AS ( - SELECT *, - ROW_NUMBER() OVER (PARTITION BY "parentId" ORDER BY "createdAt" DESC) AS rank - FROM "Comments" - WHERE "parentId"::text IN (${Prisma.join(parentCommentIds)}) - AND "deletedAt" IS NULL - ) - - SELECT id, content, "initiatorId", "initiatorType", "parentId", "taskId", "workspaceId", "createdAt", "updatedAt", "deletedAt" - FROM replies - WHERE rank <= ${limitPerParent}; - ` - } -} diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts deleted file mode 100755 index f4b66bfbc..000000000 --- a/src/app/api/comment/comment.service.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { AttachmentsService } from '@/app/api/attachments/attachments.service' -import { PublicCommentSerializer } from '@/app/api/comment/public/public.serializer' -import { sendCommentCreateNotifications } from '@/jobs/notifications' -import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' -import { InitiatedEntity } from '@/types/common' -import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' -import { CommentsPublicFilterType, CommentWithAttachments, CreateComment, UpdateComment } from '@/types/dto/comment.dto' -import { DISPATCHABLE_EVENT } from '@/types/webhook' -import { getArrayDifference, getArrayIntersection } from '@/utils/array' -import { getFileNameFromPath } from '@/utils/attachmentUtils' -import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' -import { SupabaseActions } from '@/utils/SupabaseActions' -import { getBasicPaginationAttributes } from '@/utils/pagination' -import { CommentAddedSchema } from '@api/activity-logs/schemas/CommentAddedSchema' -import { ActivityLogger } from '@api/activity-logs/services/activity-logger.service' -import { CommentRepository } from '@api/comment/comment.repository' -import APIError from '@api/core/exceptions/api' -import { BaseService } from '@api/core/services/base.service' -import { PoliciesService } from '@api/core/services/policies.service' -import { Resource } from '@api/core/types/api' -import { UserAction } from '@api/core/types/user' -import { TasksService } from '@api/tasks/tasks.service' -import { ActivityType, Comment, CommentInitiator, Prisma, PrismaClient } from '@prisma/client' -import httpStatus from 'http-status' -import { z } from 'zod' -import { getSignedUrl } from '@/utils/signUrl' -import { PublicTasksService } from '@/app/api/tasks/public/public.service' - -export class CommentService extends BaseService { - async create(data: CreateComment) { - const policyGate = new PoliciesService(this.user) - policyGate.authorize(UserAction.Create, Resource.Comment) - - const initiatorId = z.string().parse(this.user.internalUserId || this.user.clientId) - const initiatorType = this.user.internalUserId ? CommentInitiator.internalUser : CommentInitiator.client - - const task = await this.db.task.findFirst({ - where: { - id: data.taskId, - workspaceId: this.user.workspaceId, - }, - }) - if (!task) throw new APIError(httpStatus.NOT_FOUND, `Could not find task with id ${data.taskId}`) - - const comment = await this.db.comment.create({ - data: { - content: data.content, - taskId: data.taskId, - parentId: data.parentId, - workspaceId: this.user.workspaceId, - initiatorId, - // This is safe to do, since if user doesn't have both iu ID / client ID, they will be filtered out way before - initiatorType, - }, - include: { attachments: true }, - }) - - let commentToReturn = comment // return the latest comment object with attachments (if any) - try { - if (comment.content) { - const newContent = await this.updateCommentIdOfAttachmentsAfterCreation(comment.content, data.taskId, comment.id) - // mutate commentToReturn here with signed attachment urls - commentToReturn = await this.db.comment.update({ - where: { id: comment.id }, - data: { - content: newContent, - updatedAt: comment.createdAt, //dont updated the updatedAt, because it will show (edited) for recently created comments. - }, - include: { attachments: true }, - }) - console.info('CommentService#createComment | Comment content attachments updated for comment ID:', comment.id) - } - } catch (e: unknown) { - await this.db.comment.delete({ where: { id: comment.id } }) - console.error('CommentService#createComment | Rolling back comment creation', e) - } - - if (!comment.parentId) { - const activityLogger = new ActivityLogger({ taskId: data.taskId, user: this.user }) - await activityLogger.log( - ActivityType.COMMENT_ADDED, - CommentAddedSchema.parse({ - id: comment.id, - content: comment.content, - initiatorId, - initiatorType, - parentId: comment.parentId, - }), - ) - await sendCommentCreateNotifications.trigger({ user: this.user, task, comment }) - } else { - const tasksService = new TasksService(this.user) - await Promise.all([ - // Update last activity log timestamp for task even on replies so they are reflected in realtime - tasksService.setNewLastActivityLogUpdated(data.taskId), - sendReplyCreateNotifications.trigger({ user: this.user, task, comment }), - ]) - } - - // dispatch a webhook event when comment is created - await this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.CommentCreated, { - payload: await PublicCommentSerializer.serialize(commentToReturn), - workspaceId: this.user.workspaceId, - }) - - return commentToReturn - - // if (data.mentions) { - // await notificationService.createBulkNotification(NotificationTaskActions.Mentioned, task, data.mentions, { - // commentId: comment.id, - // }) - // } - } - - async delete(id: string) { - const policyGate = new PoliciesService(this.user) - policyGate.authorize(UserAction.Delete, Resource.Comment) - - const commentExists = await this.db.comment.findFirst({ where: { id } }) - if (!commentExists) throw new APIError(httpStatus.NOT_FOUND, 'The comment to delete was not found') - - // delete the comment - const comment = await this.db.comment.delete({ where: { id } }) - - // delete the related attachments as well - const attachmentService = new AttachmentsService(this.user) - await attachmentService.deleteAttachmentsOfComment(comment.id) - - // transaction that deletes the activity logs - return await this.db.$transaction(async (tx) => { - this.setTransaction(tx as PrismaClient) - const replyCounts = await this.getReplyCounts([id]) - - // Delete corresponding activity log as well, so as to remove comment from UI - // If activity log exists but comment has a `deletedAt`, show "Comment was deleted" card instead - if (!replyCounts[id]) { - // If there are 0 replies, key won't be in object - await this.deleteRelatedActivityLogs(id) - } - - // If parent comment now has no replies and is also deleted, delete parent as well - if (comment.parentId) { - const parent = await this.db.comment.findFirst({ where: { id: comment.parentId, deletedAt: undefined } }) - if (parent?.deletedAt) { - await this.deleteEmptyParentActivityLog(parent) - } - } - - const tasksService = new TasksService(this.user) - tasksService.setTransaction(tx as PrismaClient) - - await tasksService.setNewLastActivityLogUpdated(comment.taskId) - tasksService.unsetTransaction() - - this.unsetTransaction() - return { ...comment, attachments: [] } // send empty attachments array - }) - } - - private async deleteEmptyParentActivityLog(parent: Comment) { - const parentReplyCounts = await this.getReplyCounts([parent.id]) - if (!parentReplyCounts[parent.id]) { - await this.deleteRelatedActivityLogs(parent.id) - } - } - - private async deleteRelatedActivityLogs(id: string) { - // Can't use `delete` only here, but only one activity log will have details.id with commentId - await this.db.activityLog.deleteMany({ - where: { - details: { path: ['id'], equals: id }, - }, - }) - } - - async update(id: string, data: UpdateComment) { - const policyGate = new PoliciesService(this.user) - policyGate.authorize(UserAction.Update, Resource.Comment) - - const filters = { id, workspaceId: this.user.workspaceId, initiatorId: this.user.internalUserId, deletedAt: undefined } - const prevComment = await this.db.comment.findFirst({ - where: filters, - }) - if (!prevComment) throw new APIError(httpStatus.NOT_FOUND, 'The comment to update was not found') - - const comment = await this.db.comment.update({ - where: filters, - data, - }) - const tasksService = new TasksService(this.user) - await tasksService.setNewLastActivityLogUpdated(comment.taskId) - return comment - } - - async getCommentById({ id, includeAttachments }: { id: string; includeAttachments?: boolean }) { - const comment = await this.db.comment.findFirst({ - where: { id, deletedAt: undefined }, // Can also get soft deleted comments - include: { attachments: includeAttachments }, - }) - if (!comment) return null - - let initiator - if (comment?.initiatorType === CommentInitiator.internalUser) { - initiator = await this.copilot.getInternalUser(comment.initiatorId) - } else if (comment?.initiatorType === CommentInitiator.client) { - initiator = await this.copilot.getClient(comment.initiatorId) - } else { - try { - initiator = await this.copilot.getInternalUser(comment.initiatorId) - } catch (e) { - initiator = await this.copilot.getClient(comment.initiatorId) - } - } - - return { ...comment, initiator } - } - - async getCommentsByIds(commentIds: string[]) { - return await this.db.comment.findMany({ - where: { - id: { in: commentIds }, - deletedAt: undefined, // Also get deleted comments (to show if comment parent was deleted) - }, - }) - } - - async getComments({ parentId }: { parentId: string }) { - return await this.db.comment.findMany({ - where: { - parentId, - workspaceId: this.user.workspaceId, - }, - orderBy: { createdAt: 'asc' }, - }) - } - - /** - * Returns an object with parentId as key and array of reply comments containing that comment as parentId - * as value - */ - async getReplyCounts(commentIds: string[]): Promise> { - if (!commentIds) return {} - - const result = await this.db.comment.groupBy({ - by: ['parentId'], - where: { - parentId: { in: commentIds }, - workspaceId: this.user.workspaceId, - deletedAt: null, - }, - _count: { id: true }, - }) - const counts: Record = {} - result.forEach((row) => row.parentId && (counts[row.parentId] = row._count.id)) - return counts - } - - /** - * Gets the first 0 - n number of unique initiators for a comment thread based on the parentIds - */ - async getThreadInitiators( - commentIds: string[], - opts: { - limit?: number - } = { limit: 3 }, - ) { - if (!commentIds.length) return {} - const commentRepo = new CommentRepository(this.user) - const results = await commentRepo.getFirstCommentInitiators(commentIds, opts.limit) - - const initiators: Record = {} - // Extract initiator ids - for (let { parentId, initiatorId, initiatorType } of results) { - if (!parentId) continue - initiators[parentId] ??= [] - initiators[parentId].push(initiatorId) - } - - return initiators - } - - async getReplies(commentIds: string[], expandComments: string[] = []) { - if (!commentIds.length) return [] - - let replies: Comment[] = [] - - // Exclude any expandComments that aren't in commentIds so user can't inject - // random ids to access comments outside of their scope - const validExpandComments = expandComments.length ? getArrayIntersection(commentIds, expandComments) : [] - // Exclude any ids already in expandComments, since this will be used to limit to 3 replies per parent - commentIds = validExpandComments.length ? getArrayDifference(commentIds, validExpandComments) : commentIds - - const commentRepo = new CommentRepository(this.user) - if (validExpandComments.length) { - const expandedReplies = await commentRepo.getAllRepliesForParents(expandComments) - replies = [...replies, ...expandedReplies] - } - const limitedReplies = await commentRepo.getLimitedRepliesForParents(commentIds) - replies = [...replies, ...limitedReplies] - - return replies - } - - async addInitiatorDetails(comments: InitiatedEntity[]) { - if (!comments.length) { - return comments - } - - const [internalUsers, clients] = await Promise.all([this.copilot.getInternalUsers(), this.copilot.getClients()]) - - return comments.map((comment) => { - let initiator - const getUser = (user: { id: string }) => user.id === comment.initiatorId - - if (comment.initiatorType === CommentInitiator.internalUser) { - initiator = internalUsers.data.find(getUser) - } else if (comment.initiatorType === CommentInitiator.client) { - initiator = clients?.data?.find(getUser) - } else { - initiator = internalUsers.data.find(getUser) || clients?.data?.find(getUser) - } - return { ...comment, initiator } - }) - } - - private async updateCommentIdOfAttachmentsAfterCreation(htmlString: string, task_id: string, commentId: string) { - const imgTagRegex = /]*src="([^"]+)"[^>]*>/g //expression used to match all img srcs in provided HTML string. - const attachmentTagRegex = /<\s*[a-zA-Z]+\s+[^>]*data-type="attachment"[^>]*src="([^"]+)"[^>]*>/g //expression used to match all attachment srcs in provided HTML string. - let match - const replacements: { originalSrc: string; newUrl: string }[] = [] - - const newFilePaths: { originalSrc: string; newFilePath: string }[] = [] - const copyAttachmentPromises: Promise[] = [] - const createAttachmentPayloads = [] - const matches: { originalSrc: string; filePath: string; fileName: string }[] = [] - - while ((match = imgTagRegex.exec(htmlString)) !== null) { - const originalSrc = match[1] - const filePath = getFilePathFromUrl(originalSrc) - const fileName = filePath?.split('/').pop() - if (filePath && fileName) { - matches.push({ originalSrc, filePath, fileName }) - } - } - - while ((match = attachmentTagRegex.exec(htmlString)) !== null) { - const originalSrc = match[1] - const filePath = getFilePathFromUrl(originalSrc) - const fileName = filePath?.split('/').pop() - if (filePath && fileName) { - matches.push({ originalSrc, filePath, fileName }) - } - } - - for (const { originalSrc, filePath, fileName } of matches) { - const newFilePath = `${this.user.workspaceId}/${task_id}/comments/${commentId}/${fileName}` - const supabaseActions = new SupabaseActions() - - const fileMetaData = await supabaseActions.getMetaData(filePath) - createAttachmentPayloads.push( - CreateAttachmentRequestSchema.parse({ - commentId: commentId, - filePath: newFilePath, - fileSize: fileMetaData?.size, - fileType: fileMetaData?.contentType, - fileName: getFileNameFromPath(newFilePath), - }), - ) - copyAttachmentPromises.push(supabaseActions.moveAttachment(filePath, newFilePath)) - newFilePaths.push({ originalSrc, newFilePath }) - } - - await Promise.all(copyAttachmentPromises) - const attachmentService = new AttachmentsService(this.user) - if (createAttachmentPayloads.length) { - await attachmentService.createMultipleAttachments(createAttachmentPayloads) - } - - const signedUrlPromises = newFilePaths.map(async ({ originalSrc, newFilePath }) => { - const newUrl = await getSignedUrl(newFilePath) - if (newUrl) { - replacements.push({ originalSrc, newUrl }) - } - }) - - await Promise.all(signedUrlPromises) - - for (const { originalSrc, newUrl } of replacements) { - htmlString = htmlString.replace(originalSrc, newUrl) - } - // const filePaths = newFilePaths.map(({ newFilePath }) => newFilePath) - // await this.db.scrapMedia.updateMany({ - // where: { - // filePath: { - // in: filePaths, - // }, - // }, - // data: { - // taskId: task_id, - // }, - // }) //todo: add support for commentId in scrapMedias. - return htmlString - } //todo: make this resuable since this is highly similar to what we are doing on tasks. - - async getAllComments(queryFilters: CommentsPublicFilterType): Promise { - const { parentId, taskId, limit, lastIdCursor, initiatorId } = queryFilters - const where: Prisma.CommentWhereInput = { - parentId, - taskId, - initiatorId, - workspaceId: this.user.workspaceId, - } - - const pagination = getBasicPaginationAttributes(limit, lastIdCursor) - if (this.user.clientId || this.user.companyId) { - where.task = this.getClientOrCompanyAssigneeFilter() - } - - return await this.db.comment.findMany({ - where, - ...pagination, - include: { attachments: true }, - orderBy: { createdAt: 'desc' }, - }) - } - - async hasMoreCommentsAfterCursor(id: string, publicFilters: Partial): Promise { - const where: Prisma.CommentWhereInput = { - ...publicFilters, - workspaceId: this.user.workspaceId, - } - if (this.user.clientId || this.user.companyId) { - where.task = this.getClientOrCompanyAssigneeFilter() - } - const newComment = await this.db.comment.findFirst({ - where, - cursor: { id }, - skip: 1, - orderBy: { createdAt: 'desc' }, - }) - return !!newComment - } - - /** - * If the user has permission to access the task, it means the user has access to the task's comments - * Therefore checking the task permission - */ - async checkCommentTaskPermissionForUser(taskId: string) { - try { - const publicTask = new PublicTasksService(this.user) - await publicTask.getOneTask(taskId) - } catch (err: unknown) { - if (err instanceof APIError) { - let status: number = httpStatus.UNAUTHORIZED, - message = 'You are not authorized to perform this action' - if (err.status === httpStatus.NOT_FOUND) { - status = httpStatus.NOT_FOUND - message = 'A task for the requested comment was not found' - } - throw new APIError(status, message) - } - throw err - } - } - - protected getClientOrCompanyAssigneeFilter(includeViewer: boolean = true): Prisma.TaskWhereInput { - const clientId = z.string().uuid().safeParse(this.user.clientId).data - const companyId = z.string().uuid().parse(this.user.companyId) - - const filters = [] - - if (clientId && companyId) { - filters.push( - // Get client tasks for the particular companyId - { clientId, companyId }, - // Get company tasks for the client's companyId - { companyId, clientId: null }, - ) - if (includeViewer) - filters.push( - // Get tasks that includes the client as a viewer - { - viewers: { - hasSome: [{ clientId, companyId }, { companyId }], - }, - }, - ) - } else if (companyId) { - filters.push( - // Get only company tasks for the client's companyId - { clientId: null, companyId }, - ) - if (includeViewer) - filters.push( - // Get tasks that includes the company as a viewer - { - viewers: { - hasSome: [{ companyId }], - }, - }, - ) - } - return filters.length > 0 ? { OR: filters } : {} - } //Repeated twice because taskSharedService is an abstract class. -} diff --git a/src/app/api/comment/public/[id]/route.ts b/src/app/api/comment/public/[id]/route.ts deleted file mode 100644 index 56b526778..000000000 --- a/src/app/api/comment/public/[id]/route.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { deleteOneCommentPublic, getOneCommentPublic } from '@/app/api/comment/public/public.controller' -import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' - -export const GET = withErrorHandler(getOneCommentPublic) -export const DELETE = withErrorHandler(deleteOneCommentPublic) diff --git a/src/app/api/comment/public/public.controller.ts b/src/app/api/comment/public/public.controller.ts deleted file mode 100644 index 19efc7171..000000000 --- a/src/app/api/comment/public/public.controller.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { CommentService } from '@/app/api/comment/comment.service' -import { PublicCommentSerializer } from '@/app/api/comment/public/public.serializer' -import authenticate from '@/app/api/core/utils/authenticate' -import { CommentsPublicFilterType } from '@/types/dto/comment.dto' -import { getPaginationLimit } from '@/utils/pagination' -import { getSearchParams } from '@/utils/request' -import { decode, encode } from 'js-base64' -import { NextRequest, NextResponse } from 'next/server' - -type TaskAndCommentIdParams = { - params: Promise<{ id: string }> -} - -export const getAllCommentsPublic = async (req: NextRequest) => { - const user = await authenticate(req) - - const { parentCommentId, createdBy, limit, nextToken, taskId } = getSearchParams(req.nextUrl.searchParams, [ - 'parentCommentId', - 'createdBy', - 'limit', - 'nextToken', - 'taskId', - ]) - - const publicFilters: CommentsPublicFilterType = { - taskId: taskId || undefined, - parentId: parentCommentId || undefined, - initiatorId: createdBy || undefined, - } - - const commentService = new CommentService(user) - taskId && (await commentService.checkCommentTaskPermissionForUser(taskId)) // check the user accessing the comment has access to the task - - const comments = await commentService.getAllComments({ - limit: getPaginationLimit(limit), - lastIdCursor: nextToken ? decode(nextToken) : undefined, - ...publicFilters, - }) - - const lastCommentId = comments[comments.length - 1]?.id - const hasMoreComments = lastCommentId - ? await commentService.hasMoreCommentsAfterCursor(lastCommentId, publicFilters) - : false - const base64NextToken = hasMoreComments ? encode(lastCommentId) : undefined - - return NextResponse.json({ - data: await PublicCommentSerializer.serializeMany(comments), - nextToken: base64NextToken, - }) -} - -export const getOneCommentPublic = async (req: NextRequest, { params }: TaskAndCommentIdParams) => { - const { id } = await params - const user = await authenticate(req) - - const commentService = new CommentService(user) - const comment = await commentService.getCommentById({ id, includeAttachments: true }) - if (!comment) return NextResponse.json({ data: null }) - - await commentService.checkCommentTaskPermissionForUser(comment.taskId) // check the user accessing the comment has access to the task - - return NextResponse.json({ data: await PublicCommentSerializer.serialize(comment) }) -} - -export const deleteOneCommentPublic = async (req: NextRequest, { params }: TaskAndCommentIdParams) => { - const { id } = await params - const user = await authenticate(req) - - const commentService = new CommentService(user) - - const deletedComment = await commentService.delete(id) - - await commentService.checkCommentTaskPermissionForUser(deletedComment.taskId) // check the user accessing the comment has access to the task - - return NextResponse.json({ ...(await PublicCommentSerializer.serialize(deletedComment)) }) -} diff --git a/src/app/api/comment/public/public.dto.ts b/src/app/api/comment/public/public.dto.ts deleted file mode 100644 index f1ed9495f..000000000 --- a/src/app/api/comment/public/public.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { PublicAttachmentDtoSchema } from '@/app/api/attachments/public/public.dto' -import { RFC3339DateSchema } from '@/types/common' -import { AssigneeType } from '@prisma/client' -import z from 'zod' - -export const PublicCommentDtoSchema = z.object({ - id: z.string().uuid(), - object: z.literal('taskComment'), - taskId: z.string().uuid(), - parentCommentId: z.string().uuid().nullable(), - content: z.string(), - createdBy: z.string().uuid(), - createdByUserType: z.nativeEnum(AssigneeType).nullable(), - createdDate: RFC3339DateSchema, - updatedDate: RFC3339DateSchema, - deletedDate: RFC3339DateSchema.nullable(), - attachments: z.array(PublicAttachmentDtoSchema).nullable(), -}) -export type PublicCommentDto = z.infer diff --git a/src/app/api/comment/public/public.serializer.ts b/src/app/api/comment/public/public.serializer.ts deleted file mode 100644 index 85815a784..000000000 --- a/src/app/api/comment/public/public.serializer.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { PublicAttachmentSerializer } from '@/app/api/attachments/public/public.serializer' -import { PublicCommentDto, PublicCommentDtoSchema } from '@/app/api/comment/public/public.dto' -import { RFC3339DateSchema } from '@/types/common' -import { CommentWithAttachments } from '@/types/dto/comment.dto' -import { toRFC3339 } from '@/utils/dateHelper' -import { z } from 'zod' - -export class PublicCommentSerializer { - static async serializeUnsafe(comment: CommentWithAttachments): Promise { - return { - id: comment.id, - object: 'taskComment', - parentCommentId: comment.parentId, - taskId: comment.taskId, - content: comment.content, - createdBy: comment.initiatorId, - createdByUserType: comment.initiatorType, - createdDate: RFC3339DateSchema.parse(toRFC3339(comment.createdAt)), - updatedDate: RFC3339DateSchema.parse(toRFC3339(comment.updatedAt)), - deletedDate: toRFC3339(comment.deletedAt), - attachments: await PublicAttachmentSerializer.serializeAttachments({ - attachments: comment.attachments, - uploadedByUserType: comment.initiatorType, - uploadedBy: comment.initiatorId, - content: comment.content, - }), - } - } - - static async serialize(comment: CommentWithAttachments): Promise { - return PublicCommentDtoSchema.parse(await PublicCommentSerializer.serializeUnsafe(comment)) - } - - static async serializeMany(comments: CommentWithAttachments[]): Promise { - const serializedComments = await Promise.all(comments.map(async (comment) => PublicCommentSerializer.serialize(comment))) - return z.array(PublicCommentDtoSchema).parse(serializedComments) - } -} diff --git a/src/app/api/comment/public/route.ts b/src/app/api/comment/public/route.ts deleted file mode 100644 index bdf711a4b..000000000 --- a/src/app/api/comment/public/route.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { getAllCommentsPublic } from '@/app/api/comment/public/public.controller' -import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' - -export const GET = withErrorHandler(getAllCommentsPublic) diff --git a/src/app/api/comment/route.ts b/src/app/api/comment/route.ts deleted file mode 100755 index de318a163..000000000 --- a/src/app/api/comment/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' -import { createComment, getFilteredComments } from '@/app/api/comment/comment.controller' - -export const maxDuration = 300 - -export const GET = withErrorHandler(getFilteredComments) -export const POST = withErrorHandler(createComment) diff --git a/src/app/detail/[task_id]/[user_type]/actions.ts b/src/app/detail/[task_id]/[user_type]/actions.ts index 9ce9d6e3d..66f19e9d4 100644 --- a/src/app/detail/[task_id]/[user_type]/actions.ts +++ b/src/app/detail/[task_id]/[user_type]/actions.ts @@ -87,7 +87,7 @@ export const deleteAttachment = async (token: string, id: string) => { } export const postComment = async (token: string, payload: CreateComment) => { - const res = await fetch(`${apiUrl}/api/comment?token=${token}`, { + const res = await fetch(`${apiUrl}/api/comments?token=${token}`, { method: 'POST', body: JSON.stringify(payload), }) @@ -96,7 +96,7 @@ export const postComment = async (token: string, payload: CreateComment) => { } export const updateComment = async (token: string, id: string, payload: UpdateComment) => { - const res = await fetch(`${apiUrl}/api/comment/${id}?token=${token}`, { + const res = await fetch(`${apiUrl}/api/comments/${id}?token=${token}`, { method: 'PATCH', body: JSON.stringify(payload), }) @@ -105,7 +105,7 @@ export const updateComment = async (token: string, id: string, payload: UpdateCo } export const deleteComment = async (token: string, id: string) => { - await fetch(`${apiUrl}/api/comment/${id}?token=${token}`, { + await fetch(`${apiUrl}/api/comments/${id}?token=${token}`, { method: 'DELETE', }) } diff --git a/src/components/cards/CommentCard.tsx b/src/components/cards/CommentCard.tsx index 504f4af49..3c3540f03 100644 --- a/src/components/cards/CommentCard.tsx +++ b/src/components/cards/CommentCard.tsx @@ -173,7 +173,7 @@ export const CommentCard = ({ const replyCount = (comment.details as CommentResponse).replyCount - const cacheKey = `/api/comment/?token=${token}&parentId=${comment.details.id}` + const cacheKey = `/api/comments/?token=${token}&parentId=${comment.details.id}` const { trigger } = useSWRMutation(cacheKey, fetcher, { optimisticData: optimisticUpdates.filter((update) => update.tempId), }) diff --git a/src/jobs/notifications/send-reply-create-notifications.ts b/src/jobs/notifications/send-reply-create-notifications.ts index def65a295..8fbd54aca 100644 --- a/src/jobs/notifications/send-reply-create-notifications.ts +++ b/src/jobs/notifications/send-reply-create-notifications.ts @@ -2,8 +2,8 @@ import { NotificationSender, NotificationSenderSchema } from '@/types/common' import { getAssigneeName } from '@/utils/assignee' import { copilotBottleneck } from '@/utils/bottleneck' import { CopilotAPI } from '@/utils/CopilotAPI' -import { CommentRepository } from '@api/comment/comment.repository' -import { CommentService } from '@api/comment/comment.service' +import { CommentRepository } from '@/app/api/comments/comment.repository' +import { CommentService } from '@/app/api/comments/comment.service' import User from '@api/core/models/User.model' import { TasksService } from '@api/tasks/tasks.service' import { Comment, CommentInitiator, Task } from '@prisma/client' From 3b9a75de7bbc613cda19aef88179a33890dc6552 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 27 Jan 2026 16:03:21 +0545 Subject: [PATCH 43/52] fix(OUT-3009): applied requested changes, changed api route from api/comment to api/comments --- src/app/api/comments/[id]/route.ts | 5 + src/app/api/comments/comment.controller.ts | 54 ++ src/app/api/comments/comment.repository.ts | 57 ++ src/app/api/comments/comment.service.ts | 505 ++++++++++++++++++ src/app/api/comments/public/[id]/route.ts | 5 + .../api/comments/public/public.controller.ts | 78 +++ src/app/api/comments/public/public.dto.ts | 19 + .../api/comments/public/public.serializer.ts | 38 ++ src/app/api/comments/public/route.ts | 4 + src/app/api/comments/route.ts | 7 + 10 files changed, 772 insertions(+) create mode 100755 src/app/api/comments/[id]/route.ts create mode 100755 src/app/api/comments/comment.controller.ts create mode 100644 src/app/api/comments/comment.repository.ts create mode 100755 src/app/api/comments/comment.service.ts create mode 100644 src/app/api/comments/public/[id]/route.ts create mode 100644 src/app/api/comments/public/public.controller.ts create mode 100644 src/app/api/comments/public/public.dto.ts create mode 100644 src/app/api/comments/public/public.serializer.ts create mode 100644 src/app/api/comments/public/route.ts create mode 100755 src/app/api/comments/route.ts diff --git a/src/app/api/comments/[id]/route.ts b/src/app/api/comments/[id]/route.ts new file mode 100755 index 000000000..f1d261c73 --- /dev/null +++ b/src/app/api/comments/[id]/route.ts @@ -0,0 +1,5 @@ +import { withErrorHandler } from '@api/core/utils/withErrorHandler' +import { deleteComment, updateComment } from '@/app/api/comments/comment.controller' + +export const PATCH = withErrorHandler(updateComment) +export const DELETE = withErrorHandler(deleteComment) diff --git a/src/app/api/comments/comment.controller.ts b/src/app/api/comments/comment.controller.ts new file mode 100755 index 000000000..ea839d528 --- /dev/null +++ b/src/app/api/comments/comment.controller.ts @@ -0,0 +1,54 @@ +import { CreateCommentSchema, UpdateCommentSchema } from '@/types/dto/comment.dto' +import { getSearchParams } from '@/utils/request' +import { signMediaForComments } from '@/utils/signedUrlReplacer' +import { CommentService } from '@/app/api/comments/comment.service' +import { IdParams } from '@api/core/types/api' +import authenticate from '@api/core/utils/authenticate' +import httpStatus from 'http-status' +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' + +export const createComment = async (req: NextRequest) => { + const user = await authenticate(req) + + const commentService = new CommentService(user) + const data = CreateCommentSchema.parse(await req.json()) + const comment = await commentService.create(data) + return NextResponse.json({ comment }, { status: httpStatus.CREATED }) +} + +export const deleteComment = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params + const user = await authenticate(req) + + const commentService = new CommentService(user) + await commentService.delete(id) + //Can't use status code 204 in NextResponse as of now - https://github.com/vercel/next.js/discussions/51475 + //Using Response is also not allowed since withErrorHandler wrapper uses NextResponse. + return NextResponse.json({ message: 'Comment deleted!' }) +} + +export const updateComment = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params + const user = await authenticate(req) + + const data = UpdateCommentSchema.parse(await req.json()) + const commentService = new CommentService(user) + const comment = await commentService.update(id, data) + + return NextResponse.json({ comment }) +} + +export const getFilteredComments = async (req: NextRequest) => { + const user = await authenticate(req) + + const { parentId: rawParentId } = getSearchParams(req.nextUrl.searchParams, ['parentId']) + const parentId = z.string().uuid().parse(rawParentId) + const commentService = new CommentService(user) + const comments = await commentService.getComments({ parentId }) + const signedComments = await signMediaForComments(comments) + + return NextResponse.json({ + comments: await commentService.addInitiatorDetails(signedComments), + }) +} diff --git a/src/app/api/comments/comment.repository.ts b/src/app/api/comments/comment.repository.ts new file mode 100644 index 000000000..104ffe979 --- /dev/null +++ b/src/app/api/comments/comment.repository.ts @@ -0,0 +1,57 @@ +import { BaseRepository } from '@api/core/repository/base.repository' +import { Comment, CommentInitiator, Prisma } from '@prisma/client' + +type CommentInitiatorResult = { parentId: string; initiatorId: string; initiatorType: CommentInitiator } + +export class CommentRepository extends BaseRepository { + async getFirstCommentInitiators( + parentCommentIds: string[], + limitPerParent: number = 3, + ): Promise { + const results = await this.db.$queryRaw` + WITH ranked_comments AS ( + SELECT "parentId", "initiatorId", "initiatorType", + -- Use DENSE_RANK to ensure ranking is based on earliest time based on createdAt + DENSE_RANK() OVER ( + PARTITION BY "parentId" ORDER BY MIN("createdAt") ASC + ) AS rank_num + FROM "Comments" + WHERE "parentId"::text IN (${Prisma.join(parentCommentIds)}) + AND "deletedAt" IS NULL + -- Ensures one initiatorId appears only ONCE per parentId (hopefully) + GROUP BY "parentId", "initiatorId", "initiatorType" + ) + SELECT "parentId", "initiatorId", "initiatorType" + FROM ranked_comments + WHERE rank_num <= ${limitPerParent}; + ` + return results + } + + async getAllRepliesForParents(parentCommentIds: string[]): Promise { + return await this.db.comment.findMany({ + where: { + parentId: { in: parentCommentIds }, + workspaceId: this.user.workspaceId, + }, + orderBy: { createdAt: 'desc' }, + }) + } + + async getLimitedRepliesForParents(parentCommentIds: string[], limitPerParent: number = 3): Promise { + // IMPORTANT: If you change the schema of Comments table be sure to add them here too. + return await this.db.$queryRaw` + WITH replies AS ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY "parentId" ORDER BY "createdAt" DESC) AS rank + FROM "Comments" + WHERE "parentId"::text IN (${Prisma.join(parentCommentIds)}) + AND "deletedAt" IS NULL + ) + + SELECT id, content, "initiatorId", "initiatorType", "parentId", "taskId", "workspaceId", "createdAt", "updatedAt", "deletedAt" + FROM replies + WHERE rank <= ${limitPerParent}; + ` + } +} diff --git a/src/app/api/comments/comment.service.ts b/src/app/api/comments/comment.service.ts new file mode 100755 index 000000000..af3b6da8e --- /dev/null +++ b/src/app/api/comments/comment.service.ts @@ -0,0 +1,505 @@ +import { AttachmentsService } from '@/app/api/attachments/attachments.service' +import { PublicCommentSerializer } from '@/app/api/comments/public/public.serializer' +import { sendCommentCreateNotifications } from '@/jobs/notifications' +import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' +import { InitiatedEntity } from '@/types/common' +import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' +import { CommentsPublicFilterType, CommentWithAttachments, CreateComment, UpdateComment } from '@/types/dto/comment.dto' +import { DISPATCHABLE_EVENT } from '@/types/webhook' +import { getArrayDifference, getArrayIntersection } from '@/utils/array' +import { getFileNameFromPath } from '@/utils/attachmentUtils' +import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' +import { SupabaseActions } from '@/utils/SupabaseActions' +import { getBasicPaginationAttributes } from '@/utils/pagination' +import { CommentAddedSchema } from '@api/activity-logs/schemas/CommentAddedSchema' +import { ActivityLogger } from '@api/activity-logs/services/activity-logger.service' +import { CommentRepository } from '@/app/api/comments/comment.repository' +import APIError from '@api/core/exceptions/api' +import { BaseService } from '@api/core/services/base.service' +import { PoliciesService } from '@api/core/services/policies.service' +import { Resource } from '@api/core/types/api' +import { UserAction } from '@api/core/types/user' +import { TasksService } from '@api/tasks/tasks.service' +import { ActivityType, Comment, CommentInitiator, Prisma, PrismaClient } from '@prisma/client' +import httpStatus from 'http-status' +import { z } from 'zod' +import { getSignedUrl } from '@/utils/signUrl' +import { PublicTasksService } from '@/app/api/tasks/public/public.service' + +export class CommentService extends BaseService { + async create(data: CreateComment) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Create, Resource.Comment) + + const initiatorId = z.string().parse(this.user.internalUserId || this.user.clientId) + const initiatorType = this.user.internalUserId ? CommentInitiator.internalUser : CommentInitiator.client + + const task = await this.db.task.findFirst({ + where: { + id: data.taskId, + workspaceId: this.user.workspaceId, + }, + }) + if (!task) throw new APIError(httpStatus.NOT_FOUND, `Could not find task with id ${data.taskId}`) + + const comment = await this.db.comment.create({ + data: { + content: data.content, + taskId: data.taskId, + parentId: data.parentId, + workspaceId: this.user.workspaceId, + initiatorId, + // This is safe to do, since if user doesn't have both iu ID / client ID, they will be filtered out way before + initiatorType, + }, + include: { attachments: true }, + }) + + let commentToReturn = comment // return the latest comment object with attachments (if any) + try { + if (comment.content) { + const newContent = await this.updateCommentIdOfAttachmentsAfterCreation(comment.content, data.taskId, comment.id) + // mutate commentToReturn here with signed attachment urls + commentToReturn = await this.db.comment.update({ + where: { id: comment.id }, + data: { + content: newContent, + updatedAt: comment.createdAt, //dont updated the updatedAt, because it will show (edited) for recently created comments. + }, + include: { attachments: true }, + }) + console.info('CommentService#createComment | Comment content attachments updated for comment ID:', comment.id) + } + } catch (e: unknown) { + await this.db.comment.delete({ where: { id: comment.id } }) + console.error('CommentService#createComment | Rolling back comment creation', e) + } + + if (!comment.parentId) { + const activityLogger = new ActivityLogger({ taskId: data.taskId, user: this.user }) + await activityLogger.log( + ActivityType.COMMENT_ADDED, + CommentAddedSchema.parse({ + id: comment.id, + content: comment.content, + initiatorId, + initiatorType, + parentId: comment.parentId, + }), + ) + await sendCommentCreateNotifications.trigger({ user: this.user, task, comment }) + } else { + const tasksService = new TasksService(this.user) + await Promise.all([ + // Update last activity log timestamp for task even on replies so they are reflected in realtime + tasksService.setNewLastActivityLogUpdated(data.taskId), + sendReplyCreateNotifications.trigger({ user: this.user, task, comment }), + ]) + } + + // dispatch a webhook event when comment is created + await this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.CommentCreated, { + payload: await PublicCommentSerializer.serialize(commentToReturn), + workspaceId: this.user.workspaceId, + }) + + return commentToReturn + + // if (data.mentions) { + // await notificationService.createBulkNotification(NotificationTaskActions.Mentioned, task, data.mentions, { + // commentId: comment.id, + // }) + // } + } + + async delete(id: string) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Delete, Resource.Comment) + + const commentExists = await this.db.comment.findFirst({ where: { id } }) + if (!commentExists) throw new APIError(httpStatus.NOT_FOUND, 'The comment to delete was not found') + + // delete the comment + const comment = await this.db.comment.delete({ where: { id } }) + + // delete the related attachments as well + const attachmentService = new AttachmentsService(this.user) + await attachmentService.deleteAttachmentsOfComment(comment.id) + + // transaction that deletes the activity logs + return await this.db.$transaction(async (tx) => { + this.setTransaction(tx as PrismaClient) + const replyCounts = await this.getReplyCounts([id]) + + // Delete corresponding activity log as well, so as to remove comment from UI + // If activity log exists but comment has a `deletedAt`, show "Comment was deleted" card instead + if (!replyCounts[id]) { + // If there are 0 replies, key won't be in object + await this.deleteRelatedActivityLogs(id) + } + + // If parent comment now has no replies and is also deleted, delete parent as well + if (comment.parentId) { + const parent = await this.db.comment.findFirst({ where: { id: comment.parentId, deletedAt: undefined } }) + if (parent?.deletedAt) { + await this.deleteEmptyParentActivityLog(parent) + } + } + + const tasksService = new TasksService(this.user) + tasksService.setTransaction(tx as PrismaClient) + + await tasksService.setNewLastActivityLogUpdated(comment.taskId) + tasksService.unsetTransaction() + + this.unsetTransaction() + return { ...comment, attachments: [] } // send empty attachments array + }) + } + + private async deleteEmptyParentActivityLog(parent: Comment) { + const parentReplyCounts = await this.getReplyCounts([parent.id]) + if (!parentReplyCounts[parent.id]) { + await this.deleteRelatedActivityLogs(parent.id) + } + } + + private async deleteRelatedActivityLogs(id: string) { + // Can't use `delete` only here, but only one activity log will have details.id with commentId + await this.db.activityLog.deleteMany({ + where: { + details: { path: ['id'], equals: id }, + }, + }) + } + + async update(id: string, data: UpdateComment) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Update, Resource.Comment) + + const filters = { id, workspaceId: this.user.workspaceId, initiatorId: this.user.internalUserId, deletedAt: undefined } + const prevComment = await this.db.comment.findFirst({ + where: filters, + }) + if (!prevComment) throw new APIError(httpStatus.NOT_FOUND, 'The comment to update was not found') + + const comment = await this.db.comment.update({ + where: filters, + data, + }) + const tasksService = new TasksService(this.user) + await tasksService.setNewLastActivityLogUpdated(comment.taskId) + return comment + } + + async getCommentById({ id, includeAttachments }: { id: string; includeAttachments?: boolean }) { + const comment = await this.db.comment.findFirst({ + where: { id, deletedAt: undefined }, // Can also get soft deleted comments + include: { attachments: includeAttachments }, + }) + if (!comment) return null + + let initiator + if (comment?.initiatorType === CommentInitiator.internalUser) { + initiator = await this.copilot.getInternalUser(comment.initiatorId) + } else if (comment?.initiatorType === CommentInitiator.client) { + initiator = await this.copilot.getClient(comment.initiatorId) + } else { + try { + initiator = await this.copilot.getInternalUser(comment.initiatorId) + } catch (e) { + initiator = await this.copilot.getClient(comment.initiatorId) + } + } + + return { ...comment, initiator } + } + + async getCommentsByIds(commentIds: string[]) { + return await this.db.comment.findMany({ + where: { + id: { in: commentIds }, + deletedAt: undefined, // Also get deleted comments (to show if comment parent was deleted) + }, + }) + } + + async getComments({ parentId }: { parentId: string }) { + return await this.db.comment.findMany({ + where: { + parentId, + workspaceId: this.user.workspaceId, + }, + orderBy: { createdAt: 'asc' }, + }) + } + + /** + * Returns an object with parentId as key and array of reply comments containing that comment as parentId + * as value + */ + async getReplyCounts(commentIds: string[]): Promise> { + if (!commentIds) return {} + + const result = await this.db.comment.groupBy({ + by: ['parentId'], + where: { + parentId: { in: commentIds }, + workspaceId: this.user.workspaceId, + deletedAt: null, + }, + _count: { id: true }, + }) + const counts: Record = {} + result.forEach((row) => row.parentId && (counts[row.parentId] = row._count.id)) + return counts + } + + /** + * Gets the first 0 - n number of unique initiators for a comment thread based on the parentIds + */ + async getThreadInitiators( + commentIds: string[], + opts: { + limit?: number + } = { limit: 3 }, + ) { + if (!commentIds.length) return {} + const commentRepo = new CommentRepository(this.user) + const results = await commentRepo.getFirstCommentInitiators(commentIds, opts.limit) + + const initiators: Record = {} + // Extract initiator ids + for (let { parentId, initiatorId, initiatorType } of results) { + if (!parentId) continue + initiators[parentId] ??= [] + initiators[parentId].push(initiatorId) + } + + return initiators + } + + async getReplies(commentIds: string[], expandComments: string[] = []) { + if (!commentIds.length) return [] + + let replies: Comment[] = [] + + // Exclude any expandComments that aren't in commentIds so user can't inject + // random ids to access comments outside of their scope + const validExpandComments = expandComments.length ? getArrayIntersection(commentIds, expandComments) : [] + // Exclude any ids already in expandComments, since this will be used to limit to 3 replies per parent + commentIds = validExpandComments.length ? getArrayDifference(commentIds, validExpandComments) : commentIds + + const commentRepo = new CommentRepository(this.user) + if (validExpandComments.length) { + const expandedReplies = await commentRepo.getAllRepliesForParents(expandComments) + replies = [...replies, ...expandedReplies] + } + const limitedReplies = await commentRepo.getLimitedRepliesForParents(commentIds) + replies = [...replies, ...limitedReplies] + + return replies + } + + async addInitiatorDetails(comments: InitiatedEntity[]) { + if (!comments.length) { + return comments + } + + const [internalUsers, clients] = await Promise.all([this.copilot.getInternalUsers(), this.copilot.getClients()]) + + return comments.map((comment) => { + let initiator + const getUser = (user: { id: string }) => user.id === comment.initiatorId + + if (comment.initiatorType === CommentInitiator.internalUser) { + initiator = internalUsers.data.find(getUser) + } else if (comment.initiatorType === CommentInitiator.client) { + initiator = clients?.data?.find(getUser) + } else { + initiator = internalUsers.data.find(getUser) || clients?.data?.find(getUser) + } + return { ...comment, initiator } + }) + } + + private async updateCommentIdOfAttachmentsAfterCreation(htmlString: string, task_id: string, commentId: string) { + const imgTagRegex = /]*src="([^"]+)"[^>]*>/g //expression used to match all img srcs in provided HTML string. + const attachmentTagRegex = /<\s*[a-zA-Z]+\s+[^>]*data-type="attachment"[^>]*src="([^"]+)"[^>]*>/g //expression used to match all attachment srcs in provided HTML string. + let match + const replacements: { originalSrc: string; newUrl: string }[] = [] + + const newFilePaths: { originalSrc: string; newFilePath: string }[] = [] + const copyAttachmentPromises: Promise[] = [] + const createAttachmentPayloads = [] + const matches: { originalSrc: string; filePath: string; fileName: string }[] = [] + + while ((match = imgTagRegex.exec(htmlString)) !== null) { + const originalSrc = match[1] + const filePath = getFilePathFromUrl(originalSrc) + const fileName = filePath?.split('/').pop() + if (filePath && fileName) { + matches.push({ originalSrc, filePath, fileName }) + } + } + + while ((match = attachmentTagRegex.exec(htmlString)) !== null) { + const originalSrc = match[1] + const filePath = getFilePathFromUrl(originalSrc) + const fileName = filePath?.split('/').pop() + if (filePath && fileName) { + matches.push({ originalSrc, filePath, fileName }) + } + } + + for (const { originalSrc, filePath, fileName } of matches) { + const newFilePath = `${this.user.workspaceId}/${task_id}/comments/${commentId}/${fileName}` + const supabaseActions = new SupabaseActions() + + const fileMetaData = await supabaseActions.getMetaData(filePath) + createAttachmentPayloads.push( + CreateAttachmentRequestSchema.parse({ + commentId: commentId, + filePath: newFilePath, + fileSize: fileMetaData?.size, + fileType: fileMetaData?.contentType, + fileName: getFileNameFromPath(newFilePath), + }), + ) + copyAttachmentPromises.push(supabaseActions.moveAttachment(filePath, newFilePath)) + newFilePaths.push({ originalSrc, newFilePath }) + } + + await Promise.all(copyAttachmentPromises) + const attachmentService = new AttachmentsService(this.user) + if (createAttachmentPayloads.length) { + await attachmentService.createMultipleAttachments(createAttachmentPayloads) + } + + const signedUrlPromises = newFilePaths.map(async ({ originalSrc, newFilePath }) => { + const newUrl = await getSignedUrl(newFilePath) + if (newUrl) { + replacements.push({ originalSrc, newUrl }) + } + }) + + await Promise.all(signedUrlPromises) + + for (const { originalSrc, newUrl } of replacements) { + htmlString = htmlString.replace(originalSrc, newUrl) + } + // const filePaths = newFilePaths.map(({ newFilePath }) => newFilePath) + // await this.db.scrapMedia.updateMany({ + // where: { + // filePath: { + // in: filePaths, + // }, + // }, + // data: { + // taskId: task_id, + // }, + // }) //todo: add support for commentId in scrapMedias. + return htmlString + } //todo: make this resuable since this is highly similar to what we are doing on tasks. + + async getAllComments(queryFilters: CommentsPublicFilterType): Promise { + const { parentId, taskId, limit, lastIdCursor, initiatorId } = queryFilters + const where: Prisma.CommentWhereInput = { + parentId, + taskId, + initiatorId, + workspaceId: this.user.workspaceId, + } + + const pagination = getBasicPaginationAttributes(limit, lastIdCursor) + if (this.user.clientId || this.user.companyId) { + where.task = this.getClientOrCompanyAssigneeFilter() + } + + return await this.db.comment.findMany({ + where, + ...pagination, + include: { attachments: true }, + orderBy: { createdAt: 'desc' }, + }) + } + + async hasMoreCommentsAfterCursor(id: string, publicFilters: Partial): Promise { + const where: Prisma.CommentWhereInput = { + ...publicFilters, + workspaceId: this.user.workspaceId, + } + if (this.user.clientId || this.user.companyId) { + where.task = this.getClientOrCompanyAssigneeFilter() + } + const newComment = await this.db.comment.findFirst({ + where, + cursor: { id }, + skip: 1, + orderBy: { createdAt: 'desc' }, + }) + return !!newComment + } + + /** + * If the user has permission to access the task, it means the user has access to the task's comments + * Therefore checking the task permission + */ + async checkCommentTaskPermissionForUser(taskId: string) { + try { + const publicTask = new PublicTasksService(this.user) + await publicTask.getOneTask(taskId) + } catch (err: unknown) { + if (err instanceof APIError) { + let status: number = httpStatus.UNAUTHORIZED, + message = 'You are not authorized to perform this action' + if (err.status === httpStatus.NOT_FOUND) { + status = httpStatus.NOT_FOUND + message = 'A task for the requested comment was not found' + } + throw new APIError(status, message) + } + throw err + } + } + + protected getClientOrCompanyAssigneeFilter(includeViewer: boolean = true): Prisma.TaskWhereInput { + const clientId = z.string().uuid().safeParse(this.user.clientId).data + const companyId = z.string().uuid().parse(this.user.companyId) + + const filters = [] + + if (clientId && companyId) { + filters.push( + // Get client tasks for the particular companyId + { clientId, companyId }, + // Get company tasks for the client's companyId + { companyId, clientId: null }, + ) + if (includeViewer) + filters.push( + // Get tasks that includes the client as a viewer + { + viewers: { + hasSome: [{ clientId, companyId }, { companyId }], + }, + }, + ) + } else if (companyId) { + filters.push( + // Get only company tasks for the client's companyId + { clientId: null, companyId }, + ) + if (includeViewer) + filters.push( + // Get tasks that includes the company as a viewer + { + viewers: { + hasSome: [{ companyId }], + }, + }, + ) + } + return filters.length > 0 ? { OR: filters } : {} + } //Repeated twice because taskSharedService is an abstract class. +} diff --git a/src/app/api/comments/public/[id]/route.ts b/src/app/api/comments/public/[id]/route.ts new file mode 100644 index 000000000..b9864d29b --- /dev/null +++ b/src/app/api/comments/public/[id]/route.ts @@ -0,0 +1,5 @@ +import { deleteOneCommentPublic, getOneCommentPublic } from '@/app/api/comments/public/public.controller' +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' + +export const GET = withErrorHandler(getOneCommentPublic) +export const DELETE = withErrorHandler(deleteOneCommentPublic) diff --git a/src/app/api/comments/public/public.controller.ts b/src/app/api/comments/public/public.controller.ts new file mode 100644 index 000000000..994dd13d3 --- /dev/null +++ b/src/app/api/comments/public/public.controller.ts @@ -0,0 +1,78 @@ +import { CommentService } from '@/app/api/comments/comment.service' +import { PublicCommentSerializer } from '@/app/api/comments/public/public.serializer' +import authenticate from '@/app/api/core/utils/authenticate' +import { CommentsPublicFilterType } from '@/types/dto/comment.dto' +import { getPaginationLimit } from '@/utils/pagination' +import { getSearchParams } from '@/utils/request' +import { decode, encode } from 'js-base64' +import { NextRequest, NextResponse } from 'next/server' + +type TaskAndCommentIdParams = { + params: Promise<{ id: string }> +} + +export const getAllCommentsPublic = async (req: NextRequest) => { + const user = await authenticate(req) + + const { parentCommentId, createdBy, limit, nextToken, taskId } = getSearchParams(req.nextUrl.searchParams, [ + 'parentCommentId', + 'createdBy', + 'limit', + 'nextToken', + 'taskId', + ]) + + const publicFilters: CommentsPublicFilterType = { + taskId: taskId || undefined, + parentId: parentCommentId || undefined, + initiatorId: createdBy || undefined, + } + + const commentService = new CommentService(user) + if (taskId) { + taskId && (await commentService.checkCommentTaskPermissionForUser(taskId)) // check the user accessing the comment has access to the task + } + + const comments = await commentService.getAllComments({ + limit: getPaginationLimit(limit), + lastIdCursor: nextToken ? decode(nextToken) : undefined, + ...publicFilters, + }) + + const lastCommentId = comments[comments.length - 1]?.id + const hasMoreComments = lastCommentId + ? await commentService.hasMoreCommentsAfterCursor(lastCommentId, publicFilters) + : false + const base64NextToken = hasMoreComments ? encode(lastCommentId) : undefined + + return NextResponse.json({ + data: await PublicCommentSerializer.serializeMany(comments), + nextToken: base64NextToken, + }) +} + +export const getOneCommentPublic = async (req: NextRequest, { params }: TaskAndCommentIdParams) => { + const { id } = await params + const user = await authenticate(req) + + const commentService = new CommentService(user) + const comment = await commentService.getCommentById({ id, includeAttachments: true }) + if (!comment) return NextResponse.json({ data: null }) + + await commentService.checkCommentTaskPermissionForUser(comment.taskId) // check the user accessing the comment has access to the task + + return NextResponse.json({ data: await PublicCommentSerializer.serialize(comment) }) +} + +export const deleteOneCommentPublic = async (req: NextRequest, { params }: TaskAndCommentIdParams) => { + const { id } = await params + const user = await authenticate(req) + + const commentService = new CommentService(user) + + const deletedComment = await commentService.delete(id) + + await commentService.checkCommentTaskPermissionForUser(deletedComment.taskId) // check the user accessing the comment has access to the task + + return NextResponse.json({ ...(await PublicCommentSerializer.serialize(deletedComment)) }) +} diff --git a/src/app/api/comments/public/public.dto.ts b/src/app/api/comments/public/public.dto.ts new file mode 100644 index 000000000..f1ed9495f --- /dev/null +++ b/src/app/api/comments/public/public.dto.ts @@ -0,0 +1,19 @@ +import { PublicAttachmentDtoSchema } from '@/app/api/attachments/public/public.dto' +import { RFC3339DateSchema } from '@/types/common' +import { AssigneeType } from '@prisma/client' +import z from 'zod' + +export const PublicCommentDtoSchema = z.object({ + id: z.string().uuid(), + object: z.literal('taskComment'), + taskId: z.string().uuid(), + parentCommentId: z.string().uuid().nullable(), + content: z.string(), + createdBy: z.string().uuid(), + createdByUserType: z.nativeEnum(AssigneeType).nullable(), + createdDate: RFC3339DateSchema, + updatedDate: RFC3339DateSchema, + deletedDate: RFC3339DateSchema.nullable(), + attachments: z.array(PublicAttachmentDtoSchema).nullable(), +}) +export type PublicCommentDto = z.infer diff --git a/src/app/api/comments/public/public.serializer.ts b/src/app/api/comments/public/public.serializer.ts new file mode 100644 index 000000000..989533009 --- /dev/null +++ b/src/app/api/comments/public/public.serializer.ts @@ -0,0 +1,38 @@ +import { PublicAttachmentSerializer } from '@/app/api/attachments/public/public.serializer' +import { PublicCommentDto, PublicCommentDtoSchema } from '@/app/api/comments/public/public.dto' +import { RFC3339DateSchema } from '@/types/common' +import { CommentWithAttachments } from '@/types/dto/comment.dto' +import { toRFC3339 } from '@/utils/dateHelper' +import { z } from 'zod' + +export class PublicCommentSerializer { + static async serializeUnsafe(comment: CommentWithAttachments): Promise { + return { + id: comment.id, + object: 'taskComment', + parentCommentId: comment.parentId, + taskId: comment.taskId, + content: comment.content, + createdBy: comment.initiatorId, + createdByUserType: comment.initiatorType, + createdDate: RFC3339DateSchema.parse(toRFC3339(comment.createdAt)), + updatedDate: RFC3339DateSchema.parse(toRFC3339(comment.updatedAt)), + deletedDate: toRFC3339(comment.deletedAt), + attachments: await PublicAttachmentSerializer.serializeAttachments({ + attachments: comment.attachments, + uploadedByUserType: comment.initiatorType, + uploadedBy: comment.initiatorId, + content: comment.content, + }), + } + } + + static async serialize(comment: CommentWithAttachments): Promise { + return PublicCommentDtoSchema.parse(await PublicCommentSerializer.serializeUnsafe(comment)) + } + + static async serializeMany(comments: CommentWithAttachments[]): Promise { + const serializedComments = await Promise.all(comments.map(async (comment) => PublicCommentSerializer.serialize(comment))) + return z.array(PublicCommentDtoSchema).parse(serializedComments) + } +} diff --git a/src/app/api/comments/public/route.ts b/src/app/api/comments/public/route.ts new file mode 100644 index 000000000..49bdff2b0 --- /dev/null +++ b/src/app/api/comments/public/route.ts @@ -0,0 +1,4 @@ +import { getAllCommentsPublic } from '@/app/api/comments/public/public.controller' +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' + +export const GET = withErrorHandler(getAllCommentsPublic) diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts new file mode 100755 index 000000000..d4d424480 --- /dev/null +++ b/src/app/api/comments/route.ts @@ -0,0 +1,7 @@ +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' +import { createComment, getFilteredComments } from '@/app/api/comments/comment.controller' + +export const maxDuration = 300 + +export const GET = withErrorHandler(getFilteredComments) +export const POST = withErrorHandler(createComment) From 4eb3808a15af78ded39f1d6ffdce2872aa0ffdd8 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 28 Jan 2026 09:27:24 +0545 Subject: [PATCH 44/52] fix(OUT-3009): some cleaning jobs --- src/app/api/comments/comment.service.ts | 2 +- src/app/api/comments/public/public.controller.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/comments/comment.service.ts b/src/app/api/comments/comment.service.ts index af3b6da8e..9c8fc7fe8 100755 --- a/src/app/api/comments/comment.service.ts +++ b/src/app/api/comments/comment.service.ts @@ -464,7 +464,7 @@ export class CommentService extends BaseService { } protected getClientOrCompanyAssigneeFilter(includeViewer: boolean = true): Prisma.TaskWhereInput { - const clientId = z.string().uuid().safeParse(this.user.clientId).data + const clientId = z.string().uuid().parse(this.user.clientId) const companyId = z.string().uuid().parse(this.user.companyId) const filters = [] diff --git a/src/app/api/comments/public/public.controller.ts b/src/app/api/comments/public/public.controller.ts index 994dd13d3..b0766c09f 100644 --- a/src/app/api/comments/public/public.controller.ts +++ b/src/app/api/comments/public/public.controller.ts @@ -30,7 +30,7 @@ export const getAllCommentsPublic = async (req: NextRequest) => { const commentService = new CommentService(user) if (taskId) { - taskId && (await commentService.checkCommentTaskPermissionForUser(taskId)) // check the user accessing the comment has access to the task + await commentService.checkCommentTaskPermissionForUser(taskId) // check the user accessing the comment has access to the task } const comments = await commentService.getAllComments({ From 81fb0f4befba351bddd0f4a3814273a62c112ee5 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 27 Jan 2026 15:55:26 +0545 Subject: [PATCH 45/52] fix(OUT-3002): sanitized the contents and body of tasks and comments on public API response. Removed images tags, attachment tags and empty paragraph tags --- src/app/api/comments/public/public.serializer.ts | 3 ++- src/app/api/tasks/public/public.serializer.ts | 3 ++- src/utils/santizeContents.ts | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 src/utils/santizeContents.ts diff --git a/src/app/api/comments/public/public.serializer.ts b/src/app/api/comments/public/public.serializer.ts index 989533009..d0af7e223 100644 --- a/src/app/api/comments/public/public.serializer.ts +++ b/src/app/api/comments/public/public.serializer.ts @@ -3,6 +3,7 @@ import { PublicCommentDto, PublicCommentDtoSchema } from '@/app/api/comments/pub import { RFC3339DateSchema } from '@/types/common' import { CommentWithAttachments } from '@/types/dto/comment.dto' import { toRFC3339 } from '@/utils/dateHelper' +import { sanitizeHtml } from '@/utils/santizeContents' import { z } from 'zod' export class PublicCommentSerializer { @@ -12,7 +13,7 @@ export class PublicCommentSerializer { object: 'taskComment', parentCommentId: comment.parentId, taskId: comment.taskId, - content: comment.content, + content: sanitizeHtml(comment.content), createdBy: comment.initiatorId, createdByUserType: comment.initiatorType, createdDate: RFC3339DateSchema.parse(toRFC3339(comment.createdAt)), diff --git a/src/app/api/tasks/public/public.serializer.ts b/src/app/api/tasks/public/public.serializer.ts index 931aff3cf..1279802e7 100644 --- a/src/app/api/tasks/public/public.serializer.ts +++ b/src/app/api/tasks/public/public.serializer.ts @@ -10,6 +10,7 @@ import { ViewersSchema, } from '@/types/dto/tasks.dto' import { rfc3339ToDateString, toRFC3339 } from '@/utils/dateHelper' +import { sanitizeHtml } from '@/utils/santizeContents' import { copyTemplateMediaToTask } from '@/utils/signedTemplateUrlReplacer' import { replaceImageSrc } from '@/utils/signedUrlReplacer' import { getSignedUrl } from '@/utils/signUrl' @@ -40,7 +41,7 @@ export class PublicTaskSerializer { id: task.id, object: 'task', name: task.title, - description: task.body || '', + description: sanitizeHtml(task.body || ''), parentTaskId: task.parentId, dueDate: toRFC3339(task.dueDate), label: task.label, diff --git a/src/utils/santizeContents.ts b/src/utils/santizeContents.ts new file mode 100644 index 000000000..eb0b9ca39 --- /dev/null +++ b/src/utils/santizeContents.ts @@ -0,0 +1,6 @@ +export function sanitizeHtml(html: string): string { + let sanitized = html.replace(/]*>/gi, '') + sanitized = sanitized.replace(/]*>[\s\S]*?<\/div>/gi, '') + sanitized = sanitized.replace(/

\s*<\/p>/gi, '') + return sanitized +} From 291dcdcebf6315a7d120df11dbc266cf19a147ce Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 27 Jan 2026 16:10:43 +0545 Subject: [PATCH 46/52] fix(OUT-3002): added jsdoc to sanitizeContent util --- src/utils/santizeContents.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/utils/santizeContents.ts b/src/utils/santizeContents.ts index eb0b9ca39..2198d1a07 100644 --- a/src/utils/santizeContents.ts +++ b/src/utils/santizeContents.ts @@ -1,3 +1,9 @@ +/** A utility function that strips the attachment tags, image tags and all its content from task content or comment content. ONLY TO BE USED FOR PUBLIC API. + * + * @export + * @param {string} html : takes in the description of a task or content of a comment + * @returns {string} : returns the sanitized content removing useless tags causing pollution in the public API. + */ export function sanitizeHtml(html: string): string { let sanitized = html.replace(/]*>/gi, '') sanitized = sanitized.replace(/]*>[\s\S]*?<\/div>/gi, '') From 5e53c8533e92197dcb60e0e25ae24a40026e4fbf Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 28 Jan 2026 16:13:01 +0545 Subject: [PATCH 47/52] fix(OUT-3004): Comment attachment fileName should be clean filename. - Added a sanitize fileName function which strips off any uuids in the beginning of a file. - The above method is not very ideal because an original fileName could contain a UUID but this a the case im considering right now. - To prevent any of this in the future, create an extra flow to store originalFileName in the metadata of the uploaded file. While creating an attachment entry from that file, used the originalFileName metadata property to extract a clean file name. --- src/app/api/attachments/public/public.serializer.ts | 3 ++- src/app/api/comments/comment.service.ts | 2 +- src/app/api/tasks/tasksShared.service.ts | 2 +- src/utils/SupabaseActions.ts | 11 +++++++++++ src/utils/sanitizeFileName.ts | 11 +++++++++++ 5 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 src/utils/sanitizeFileName.ts diff --git a/src/app/api/attachments/public/public.serializer.ts b/src/app/api/attachments/public/public.serializer.ts index 24423d627..9757673d5 100644 --- a/src/app/api/attachments/public/public.serializer.ts +++ b/src/app/api/attachments/public/public.serializer.ts @@ -1,6 +1,7 @@ import { PublicAttachmentDto } from '@/app/api/attachments/public/public.dto' import { RFC3339DateSchema } from '@/types/common' import { toRFC3339 } from '@/utils/dateHelper' +import { sanitizeFileName } from '@/utils/sanitizeFileName' import { createSignedUrls } from '@/utils/signUrl' import { Attachment, CommentInitiator } from '@prisma/client' import z from 'zod' @@ -39,7 +40,7 @@ export class PublicAttachmentSerializer { if (!url) return null return { id: attachment.id, - fileName: attachment.fileName, + fileName: sanitizeFileName(attachment.fileName), fileSize: attachment.fileSize, mimeType: attachment.fileType, downloadUrl: attachment.deletedAt diff --git a/src/app/api/comments/comment.service.ts b/src/app/api/comments/comment.service.ts index 9c8fc7fe8..e9a8d61c8 100755 --- a/src/app/api/comments/comment.service.ts +++ b/src/app/api/comments/comment.service.ts @@ -363,7 +363,7 @@ export class CommentService extends BaseService { filePath: newFilePath, fileSize: fileMetaData?.size, fileType: fileMetaData?.contentType, - fileName: getFileNameFromPath(newFilePath), + fileName: fileMetaData?.metadata?.originalFileName || getFileNameFromPath(newFilePath), }), ) copyAttachmentPromises.push(supabaseActions.moveAttachment(filePath, newFilePath)) diff --git a/src/app/api/tasks/tasksShared.service.ts b/src/app/api/tasks/tasksShared.service.ts index 5b07daac6..fb96d6dcb 100644 --- a/src/app/api/tasks/tasksShared.service.ts +++ b/src/app/api/tasks/tasksShared.service.ts @@ -419,7 +419,7 @@ export abstract class TasksSharedService extends BaseService { filePath: newFilePath, fileSize: fileMetaData?.size, fileType: fileMetaData?.contentType, - fileName: getFileNameFromPath(newFilePath), + fileName: fileMetaData?.metadata?.originalFileName || getFileNameFromPath(newFilePath), }), ) copyAttachmentPromises.push(supabaseActions.moveAttachment(filePath, newFilePath)) diff --git a/src/utils/SupabaseActions.ts b/src/utils/SupabaseActions.ts index b78ed33a9..5da826da3 100644 --- a/src/utils/SupabaseActions.ts +++ b/src/utils/SupabaseActions.ts @@ -27,6 +27,17 @@ export class SupabaseActions extends SupabaseService { if (error) { console.error('unable to upload the file') } + if (data) { + const { error: metadataError } = await this.supabase.storage.from(supabaseBucket).update(data.path, file, { + metadata: { + originalFileName: file.name, + }, + }) + if (metadataError) { + console.error('Failed to update metadata:', metadataError) + } + } + if (data) { filePayload = { fileSize: file.size, diff --git a/src/utils/sanitizeFileName.ts b/src/utils/sanitizeFileName.ts new file mode 100644 index 000000000..91b0f0b3b --- /dev/null +++ b/src/utils/sanitizeFileName.ts @@ -0,0 +1,11 @@ +/** + * Sanitizes a Supabase stored filename back to its original format + * Removes UUID prefix and the underscore following it. ONLY TO BE USED on attachment response for public APIs. + * + * @param fileName - The stored filename with UUID prefix + * @returns The original filename + */ +export function sanitizeFileName(fileName: string): string { + const withoutUuid = fileName.replace(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/i, '') //remove the initial UUID. + return withoutUuid +} From 488c819a24a70242b7f3ed8b68625e094efeba60 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 28 Jan 2026 16:37:55 +0545 Subject: [PATCH 48/52] fix(OUT-3004): applied requested changes --- src/utils/SupabaseActions.ts | 2 -- src/utils/sanitizeFileName.ts | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/utils/SupabaseActions.ts b/src/utils/SupabaseActions.ts index 5da826da3..98068230a 100644 --- a/src/utils/SupabaseActions.ts +++ b/src/utils/SupabaseActions.ts @@ -36,9 +36,7 @@ export class SupabaseActions extends SupabaseService { if (metadataError) { console.error('Failed to update metadata:', metadataError) } - } - if (data) { filePayload = { fileSize: file.size, fileName: file.name, diff --git a/src/utils/sanitizeFileName.ts b/src/utils/sanitizeFileName.ts index 91b0f0b3b..a97d2a279 100644 --- a/src/utils/sanitizeFileName.ts +++ b/src/utils/sanitizeFileName.ts @@ -6,6 +6,5 @@ * @returns The original filename */ export function sanitizeFileName(fileName: string): string { - const withoutUuid = fileName.replace(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/i, '') //remove the initial UUID. - return withoutUuid + return fileName.replace(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/i, '') //remove the initial UUID. } From 7a5cba42859e61fe53f8081dca6f1db0b4348abb Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 28 Jan 2026 13:36:30 +0545 Subject: [PATCH 49/52] fix(OUT-3000): added a backfill script to populate initiatorIds for older comments which had the property as null --- package.json | 2 + .../index.ts | 139 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/cmd/backfill-initiatorType-in-comments/index.ts diff --git a/package.json b/package.json index 33f5f72b0..05e3c500b 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,8 @@ "cmd:delete-duplicate-notifications": "tsx ./src/cmd/delete-duplicate-notifications", "cmd:normalize-filterOptions-assignee": "tsx ./src/cmd/normalize-filterOptions-assignee", "cmd:post-deploy-m15": "tsx ./src/cmd/post-deploy-m15", + "cmd:backfill-attachments": "tsx ./src/cmd/backfill-attachments", + "cmd:backfill-initiatorType-in-comments": "tsx ./src/cmd/backfill-initiatorType-in-comments", "db:grant-supabase-privileges": "node src/lib/supabase-privilege", "deploy": "npx trigger.dev@latest deploy", "dev": "next dev", diff --git a/src/cmd/backfill-initiatorType-in-comments/index.ts b/src/cmd/backfill-initiatorType-in-comments/index.ts new file mode 100644 index 000000000..be28ab9d9 --- /dev/null +++ b/src/cmd/backfill-initiatorType-in-comments/index.ts @@ -0,0 +1,139 @@ +import DBClient from '@/lib/db' +import { Comment, CommentInitiator } from '@prisma/client' +import Bottleneck from 'bottleneck' + +const copilotAPIKey = process.env.COPILOT_API_KEY +const assemblyApiDomain = process.env.NEXT_PUBLIC_ASSEMBLY_API_DOMAIN +const COPILOT_CLIENTS_ENDPOINT = `${assemblyApiDomain}/v1/clients?limit=10000` +const COPILOT_IUS_ENDPOINT = `${assemblyApiDomain}/v1/internal-users?limit=10000` + +type WorkspaceUsersData = { + internalUser: any[] + client: any[] +} + +const fetchWithWorkspaceKey = async (url: string, workspaceId: string) => { + const resp = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + 'X-API-KEY': `${workspaceId}/${copilotAPIKey}`, + }, + }) + + if (!resp.ok) return null + return (await resp.json())?.data ?? null +} + +const getUsersMap = async (uniqueWorkspaceIds: string[]) => { + const copilotBottleneck = new Bottleneck({ maxConcurrent: 6, minTime: 200 }) + + const workspaceUsersMap: Record = {} + const failedWorkspaces: string[] = [] + let completedCount = 0 + const totalWorkspaces = uniqueWorkspaceIds.length + + console.info(`Starting to fetch data for ${totalWorkspaces} workspaces...`) + + const fetchWorkspaceData = async (workspaceId: string) => { + const [client, internalUser] = await Promise.all([ + copilotBottleneck.schedule(() => fetchWithWorkspaceKey(COPILOT_CLIENTS_ENDPOINT, workspaceId)), + copilotBottleneck.schedule(() => fetchWithWorkspaceKey(COPILOT_IUS_ENDPOINT, workspaceId)), + ]) + completedCount++ + + if (!client || !internalUser) { + failedWorkspaces.push(workspaceId) + console.warn(`[${completedCount}/${totalWorkspaces}] Failed to fetch data for workspace: ${workspaceId}`) + return + } + + workspaceUsersMap[workspaceId] = { + internalUser, + client, + } + console.info( + `[${completedCount}/${totalWorkspaces}] Fetched workspace ${workspaceId}: ${internalUser.length} internal users, ${client.length} clients`, + ) + } + + await Promise.all(uniqueWorkspaceIds.map((workspaceId) => fetchWorkspaceData(workspaceId))) + console.info(`\nCompleted fetching workspace data:`) + console.info(`Successful: ${Object.keys(workspaceUsersMap).length}`) + console.info(`Failed: ${failedWorkspaces.length}`) + return { workspaceUsersMap, failedWorkspaces } +} + +const updateComments = async ( + comments: Comment[], + workspaceUsersMap: Record, + db: ReturnType, +) => { + const failedEntries: Comment[] = [] + const internalUserIds: string[] = [] + const clientIds: string[] = [] + + for (const comment of comments) { + if (comment.initiatorType !== null) continue + + if (!workspaceUsersMap[comment.workspaceId]) { + failedEntries.push(comment) + continue + } + + const { internalUser, client } = workspaceUsersMap[comment.workspaceId] + + const isInternalUser = internalUser.some((user: any) => user.id === comment.initiatorId) + + if (isInternalUser) { + internalUserIds.push(comment.id) + continue + } + + const isClient = client.some((c: any) => c.id === comment.initiatorId) + + if (isClient) { + clientIds.push(comment.id) + continue + } + + failedEntries.push(comment) + } + + if (internalUserIds.length > 0) { + await db.comment.updateMany({ + where: { id: { in: internalUserIds } }, + data: { initiatorType: CommentInitiator.internalUser }, + }) + } + + if (clientIds.length > 0) { + await db.comment.updateMany({ + where: { id: { in: clientIds } }, + data: { initiatorType: CommentInitiator.client }, + }) + } + + console.info(`Updated ${internalUserIds.length} internal user comments`) + console.info(`Updated ${clientIds.length} client comments`) + console.info(`Failed entries: ${failedEntries.length}`) + + return { + updatedCount: internalUserIds.length + clientIds.length, + failedEntries, + } +} + +const run = async () => { + const db = DBClient.getInstance() + + const comments = await db.comment.findMany({ + where: { initiatorType: null }, + }) + + const uniqueWorkspaceIds = [...new Set(comments.map((t) => t.workspaceId))] + const { workspaceUsersMap } = await getUsersMap(uniqueWorkspaceIds) + + await updateComments(comments, workspaceUsersMap, db) +} + +run() From cd1b09eba51b8239b8db4c21b8124fb5e5e195f7 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 28 Jan 2026 17:15:19 +0545 Subject: [PATCH 50/52] fix(OUT-3000): used object map for quick lookup instead of storing ius and cus on array on backfill initiator type script for comments --- .../index.ts | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/src/cmd/backfill-initiatorType-in-comments/index.ts b/src/cmd/backfill-initiatorType-in-comments/index.ts index be28ab9d9..f2bfca1a2 100644 --- a/src/cmd/backfill-initiatorType-in-comments/index.ts +++ b/src/cmd/backfill-initiatorType-in-comments/index.ts @@ -7,10 +7,7 @@ const assemblyApiDomain = process.env.NEXT_PUBLIC_ASSEMBLY_API_DOMAIN const COPILOT_CLIENTS_ENDPOINT = `${assemblyApiDomain}/v1/clients?limit=10000` const COPILOT_IUS_ENDPOINT = `${assemblyApiDomain}/v1/internal-users?limit=10000` -type WorkspaceUsersData = { - internalUser: any[] - client: any[] -} +type InitiatorMap = Map const fetchWithWorkspaceKey = async (url: string, workspaceId: string) => { const resp = await fetch(url, { @@ -27,7 +24,7 @@ const fetchWithWorkspaceKey = async (url: string, workspaceId: string) => { const getUsersMap = async (uniqueWorkspaceIds: string[]) => { const copilotBottleneck = new Bottleneck({ maxConcurrent: 6, minTime: 200 }) - const workspaceUsersMap: Record = {} + const workspaceInitiatorMap: Record = {} const failedWorkspaces: string[] = [] let completedCount = 0 const totalWorkspaces = uniqueWorkspaceIds.length @@ -47,10 +44,16 @@ const getUsersMap = async (uniqueWorkspaceIds: string[]) => { return } - workspaceUsersMap[workspaceId] = { - internalUser, - client, - } + const initiatorMap: InitiatorMap = new Map() + internalUser.forEach((user: any) => { + initiatorMap.set(user.id, CommentInitiator.internalUser) + }) + client.forEach((c: any) => { + initiatorMap.set(c.id, CommentInitiator.client) + }) + + workspaceInitiatorMap[workspaceId] = initiatorMap + console.info( `[${completedCount}/${totalWorkspaces}] Fetched workspace ${workspaceId}: ${internalUser.length} internal users, ${client.length} clients`, ) @@ -58,14 +61,14 @@ const getUsersMap = async (uniqueWorkspaceIds: string[]) => { await Promise.all(uniqueWorkspaceIds.map((workspaceId) => fetchWorkspaceData(workspaceId))) console.info(`\nCompleted fetching workspace data:`) - console.info(`Successful: ${Object.keys(workspaceUsersMap).length}`) + console.info(`Successful: ${Object.keys(workspaceInitiatorMap).length}`) console.info(`Failed: ${failedWorkspaces.length}`) - return { workspaceUsersMap, failedWorkspaces } + return { workspaceInitiatorMap, failedWorkspaces } } const updateComments = async ( comments: Comment[], - workspaceUsersMap: Record, + workspaceInitiatorMap: Record, db: ReturnType, ) => { const failedEntries: Comment[] = [] @@ -75,28 +78,24 @@ const updateComments = async ( for (const comment of comments) { if (comment.initiatorType !== null) continue - if (!workspaceUsersMap[comment.workspaceId]) { + const initiatorMap = workspaceInitiatorMap[comment.workspaceId] + if (!initiatorMap) { failedEntries.push(comment) continue } - const { internalUser, client } = workspaceUsersMap[comment.workspaceId] - - const isInternalUser = internalUser.some((user: any) => user.id === comment.initiatorId) + const initiatorType = initiatorMap.get(comment.initiatorId) - if (isInternalUser) { - internalUserIds.push(comment.id) + if (!initiatorType) { + failedEntries.push(comment) continue } - const isClient = client.some((c: any) => c.id === comment.initiatorId) - - if (isClient) { + if (initiatorType === CommentInitiator.internalUser) { + internalUserIds.push(comment.id) + } else { clientIds.push(comment.id) - continue } - - failedEntries.push(comment) } if (internalUserIds.length > 0) { @@ -131,9 +130,9 @@ const run = async () => { }) const uniqueWorkspaceIds = [...new Set(comments.map((t) => t.workspaceId))] - const { workspaceUsersMap } = await getUsersMap(uniqueWorkspaceIds) + const { workspaceInitiatorMap } = await getUsersMap(uniqueWorkspaceIds) - await updateComments(comments, workspaceUsersMap, db) + await updateComments(comments, workspaceInitiatorMap, db) } run() From db2e39d25a0803e310d01ff7dfcb1ec546c4089b Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Fri, 30 Jan 2026 13:11:13 +0545 Subject: [PATCH 51/52] fix(OUT-3033): if comment not found, threw a 404 error with proper error message --- src/app/api/comments/comment.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/comments/comment.service.ts b/src/app/api/comments/comment.service.ts index e9a8d61c8..d59ce0698 100755 --- a/src/app/api/comments/comment.service.ts +++ b/src/app/api/comments/comment.service.ts @@ -197,7 +197,7 @@ export class CommentService extends BaseService { where: { id, deletedAt: undefined }, // Can also get soft deleted comments include: { attachments: includeAttachments }, }) - if (!comment) return null + if (!comment) throw new APIError(httpStatus.NOT_FOUND, 'The requested comment was not found') let initiator if (comment?.initiatorType === CommentInitiator.internalUser) { From a7423e1a577f05e05d1b98aea3d3fe9d46bb23cf Mon Sep 17 00:00:00 2001 From: priosshrsth Date: Fri, 16 Jan 2026 09:21:38 +0000 Subject: [PATCH 52/52] chore(out-2917): switch to avatar component from design system chore(out-2927) revert back to node 20 --- .nvmrc | 2 +- mise.toml | 2 +- src/components/inputs/CommentInput.tsx | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.nvmrc b/.nvmrc index f62f0b29f..9a2a0e219 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.19.1 +v20 diff --git a/mise.toml b/mise.toml index 0377c37fe..126f68073 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,2 @@ [tools] -node = "20.19.4" +node = "20" diff --git a/src/components/inputs/CommentInput.tsx b/src/components/inputs/CommentInput.tsx index 739f90b90..0a129f696 100644 --- a/src/components/inputs/CommentInput.tsx +++ b/src/components/inputs/CommentInput.tsx @@ -8,7 +8,6 @@ import { MAX_UPLOAD_LIMIT } from '@/constants/attachments' import { useWindowWidth } from '@/hooks/useWindowWidth' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' -import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' import { CreateComment } from '@/types/dto/comment.dto' import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils/attachmentUtils' import { createUploadFn } from '@/utils/createUploadFn'