From 9d43cf734f9004691bc2ea554b0956657f92538e Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 29 Dec 2025 15:20:32 +0545 Subject: [PATCH 01/81] chore: prep for Next.js 16 upgrade --- mcp.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 mcp.json diff --git a/mcp.json b/mcp.json new file mode 100644 index 000000000..ee5d245dc --- /dev/null +++ b/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "next-devtools": { + "command": "npx", + "args": ["-y", "next-devtools-mcp@latest"] + } + } +} From 97c974b233fe4b6522631dcfe03bb86ee7b52fef Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 30 Dec 2025 15:21:08 +0545 Subject: [PATCH 02/81] feat(OUT-2844): upgrade next version to 16 --- .eslintrc.json | 3 - next.config.js | 9 + package.json | 17 +- src/app/(home)/page.tsx | 7 +- src/app/api/activity-logs/[id]/route.ts | 6 +- .../api/attachments/attachments.controller.ts | 3 +- src/app/api/comment/comment.controller.ts | 6 +- src/app/api/core/types/api.ts | 4 +- .../[id]/activity-logs/activity.controller.ts | 3 +- src/app/api/tasks/public/public.controller.ts | 9 +- src/app/api/tasks/subtasks.controller.ts | 3 +- src/app/api/tasks/tasks.controller.ts | 15 +- .../templates/public/public.controller.ts | 3 +- .../tasks/templates/templates.controller.ts | 18 +- src/app/client/page.tsx | 3 +- src/app/detail/[task_id]/[user_type]/page.tsx | 11 +- src/app/detail/ui/TaskCardList.tsx | 8 +- .../manage-templates/[template_id]/page.tsx | 11 +- src/app/manage-templates/page.tsx | 7 +- src/app/notification-center/page.tsx | 3 +- src/app/ui/NewTaskForm.tsx | 106 +- src/app/ui/VirtualizedTasksLists.tsx | 4 +- src/components/atoms/CopilotTooltip.tsx | 2 +- src/components/atoms/Tooltip.tsx | 2 +- src/components/cards/TaskCard.tsx | 8 +- src/components/inputs/CopilotSelector.tsx | 7 +- src/components/inputs/Selector.tsx | 4 +- src/hoc/CustomScrollBar.tsx | 4 +- src/hooks/useClickOutside.ts | 2 +- tsconfig.json | 37 +- yarn.lock | 982 ++++++++++++------ 31 files changed, 845 insertions(+), 462 deletions(-) delete mode 100644 .eslintrc.json diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bffb357a7..000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/next.config.js b/next.config.js index 5fa2fe9fe..8f98216ae 100644 --- a/next.config.js +++ b/next.config.js @@ -4,6 +4,7 @@ const { withSentryConfig } = require('@sentry/nextjs') const nextConfig = { cacheMaxMemorySize: 0, experimental: { + reactCompiler: false, staleTimes: { dynamic: 0, static: 0, @@ -18,6 +19,14 @@ const nextConfig = { return config }, + turbopack: { + rules: { + '*.svg': { + loaders: ['@svgr/webpack'], + as: '*.jsx', + }, + }, + }, } module.exports = withSentryConfig(nextConfig, { diff --git a/package.json b/package.json index c1f35df2a..e771ed30c 100644 --- a/package.json +++ b/package.json @@ -29,26 +29,27 @@ "jsdom": "^25.0.1", "localforage": "^1.10.0", "marked": "^16.3.0", - "next": "14.2.35", + "next": "16.1.1", "nextjs-progressloader": "^1.2.0", "p-retry": "^6.2.0", "prisma": "^5.19.0", - "react": "^18", + "react": "19.2.3", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dnd-touch-backend": "^16.0.1", - "react-dom": "^18.3", + "react-dom": "19.2.3", "react-redux": "^9.1.0", "react-zoom-pan-pinch": "^3.7.0", "server-only": "^0.0.1", "supabase": "^2.1.1", "swr": "^2.2.5", - "tapwrite": "1.1.93", + "tapwrite": "1.1.94", "uuid": "^11.1.0", "zod": "^3.23.8" }, "description": "A comprehensive task management app", "devDependencies": { + "@eslint/eslintrc": "^3.3.3", "@faker-js/faker": "^8.4.1", "@ngrok/ngrok": "^1.4.1", "@svgr/webpack": "^8.1.0", @@ -57,12 +58,12 @@ "@types/jest": "^29.5.12", "@types/jsdom": "^21.1.7", "@types/node": "^20", - "@types/react": "^18", + "@types/react": "19.2.7", "@types/react-custom-scrollbars": "^4.0.13", - "@types/react-dom": "^18", + "@types/react-dom": "19.2.3", "autoprefixer": "^10.0.1", - "eslint": "^8", - "eslint-config-next": "14.0.4", + "eslint": "^9", + "eslint-config-next": "16.1.1", "husky": "^8.0.0", "jest": "^29.7.0", "nodemon": "^3.1.9", diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx index 55c8a8f07..0787591f3 100644 --- a/src/app/(home)/page.tsx +++ b/src/app/(home)/page.tsx @@ -74,11 +74,10 @@ export async function getViewSettings(token: string): Promise }) { + const searchParams = await props.searchParams const token = searchParams.token const parsedToken = z.string().safeParse(searchParams.token) diff --git a/src/app/api/activity-logs/[id]/route.ts b/src/app/api/activity-logs/[id]/route.ts index 446ff2007..a6d506f7a 100644 --- a/src/app/api/activity-logs/[id]/route.ts +++ b/src/app/api/activity-logs/[id]/route.ts @@ -4,7 +4,11 @@ import { ActivityLogService } from '@api/activity-logs/services/activity-log.ser import { IdParams } from '@api/core/types/api' import { unstable_noStore as noStore } from 'next/cache' -export const GET = async (req: NextRequest, { params: { id } }: IdParams) => { +export const GET = async (req: NextRequest, props: IdParams) => { + const params = await props.params + + const { id } = params + noStore() const user = await authenticate(req) diff --git a/src/app/api/attachments/attachments.controller.ts b/src/app/api/attachments/attachments.controller.ts index 40b51775b..6ef717e1f 100644 --- a/src/app/api/attachments/attachments.controller.ts +++ b/src/app/api/attachments/attachments.controller.ts @@ -40,7 +40,8 @@ export const getAttachments = async (req: NextRequest) => { return NextResponse.json({ attachments }) } -export const deleteAttachment = async (req: NextRequest, { params: { id } }: IdParams) => { +export const deleteAttachment = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const attachmentsService = new AttachmentsService(user) await attachmentsService.deleteAttachment(id) diff --git a/src/app/api/comment/comment.controller.ts b/src/app/api/comment/comment.controller.ts index 015c5f963..ea234fe52 100755 --- a/src/app/api/comment/comment.controller.ts +++ b/src/app/api/comment/comment.controller.ts @@ -17,7 +17,8 @@ export const createComment = async (req: NextRequest) => { return NextResponse.json({ comment }, { status: httpStatus.CREATED }) } -export const deleteComment = async (req: NextRequest, { params: { id } }: IdParams) => { +export const deleteComment = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const commentService = new CommentService(user) @@ -27,7 +28,8 @@ export const deleteComment = async (req: NextRequest, { params: { id } }: IdPara return NextResponse.json({ message: 'Comment deleted!' }) } -export const updateComment = async (req: NextRequest, { params: { id } }: IdParams) => { +export const updateComment = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const data = UpdateCommentSchema.parse(await req.json()) diff --git a/src/app/api/core/types/api.ts b/src/app/api/core/types/api.ts index 24c331172..6acc0d6cc 100644 --- a/src/app/api/core/types/api.ts +++ b/src/app/api/core/types/api.ts @@ -17,7 +17,5 @@ export enum Resource { * NextParam when uuid is being used as a dynamic param (slug) for accessing a resource */ export type IdParams = { - params: { - id: string - } + params: Promise<{ id: string }> } diff --git a/src/app/api/tasks/[id]/activity-logs/activity.controller.ts b/src/app/api/tasks/[id]/activity-logs/activity.controller.ts index 48e0647c8..98dac39f0 100644 --- a/src/app/api/tasks/[id]/activity-logs/activity.controller.ts +++ b/src/app/api/tasks/[id]/activity-logs/activity.controller.ts @@ -5,7 +5,8 @@ import authenticate from '@api/core/utils/authenticate' import httpStatus from 'http-status' import { NextRequest, NextResponse } from 'next/server' -export const get = async (req: NextRequest, { params: { id } }: IdParams) => { +export const get = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const { expandComments } = getSearchParams(req.nextUrl.searchParams, ['expandComments']) diff --git a/src/app/api/tasks/public/public.controller.ts b/src/app/api/tasks/public/public.controller.ts index 457daa0a2..e929d4c23 100644 --- a/src/app/api/tasks/public/public.controller.ts +++ b/src/app/api/tasks/public/public.controller.ts @@ -52,7 +52,8 @@ export const getAllTasksPublic = async (req: NextRequest) => { }) } -export const getOneTaskPublic = async (req: NextRequest, { params: { id } }: IdParams) => { +export const getOneTaskPublic = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const tasksService = new TasksService(user) const task = await tasksService.getOneTask(id, true) //from public API is true @@ -75,7 +76,8 @@ export const createTaskPublic = async (req: NextRequest) => { return NextResponse.json(PublicTaskSerializer.serialize(newTask)) } -export const updateTaskPublic = async (req: NextRequest, { params: { id } }: IdParams) => { +export const updateTaskPublic = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const data = PublicTaskUpdateDtoSchema.parse(await req.json()) @@ -86,7 +88,8 @@ export const updateTaskPublic = async (req: NextRequest, { params: { id } }: IdP return NextResponse.json(PublicTaskSerializer.serialize(updatedTask)) } -export const deleteOneTaskPublic = async (req: NextRequest, { params: { id } }: IdParams) => { +export const deleteOneTaskPublic = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const recursive = req.nextUrl.searchParams.get('recursive') const user = await authenticate(req) const tasksService = new TasksService(user) diff --git a/src/app/api/tasks/subtasks.controller.ts b/src/app/api/tasks/subtasks.controller.ts index d7bb69c99..0cc64f0e2 100644 --- a/src/app/api/tasks/subtasks.controller.ts +++ b/src/app/api/tasks/subtasks.controller.ts @@ -3,7 +3,8 @@ import authenticate from '@api/core/utils/authenticate' import { SubtaskService } from '@api/tasks/subtasks.service' import { NextRequest, NextResponse } from 'next/server' -export const getSubtaskCount = async (req: NextRequest, { params: { id } }: IdParams) => { +export const getSubtaskCount = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const subtaskService = new SubtaskService(user) const count = await subtaskService.getSubtaskCounts(id) diff --git a/src/app/api/tasks/tasks.controller.ts b/src/app/api/tasks/tasks.controller.ts index 413a3cb1a..b5972d02b 100644 --- a/src/app/api/tasks/tasks.controller.ts +++ b/src/app/api/tasks/tasks.controller.ts @@ -49,7 +49,8 @@ export const createTask = async (req: NextRequest) => { return NextResponse.json(newTask, { status: httpStatus.CREATED }) } -export const getTask = async (req: NextRequest, { params: { id } }: IdParams) => { +export const getTask = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const tasksService = new TasksService(user) const task = await tasksService.getOneTask(id) @@ -57,7 +58,8 @@ export const getTask = async (req: NextRequest, { params: { id } }: IdParams) => return NextResponse.json({ task: { ...task, assignee } }) } -export const updateTask = async (req: NextRequest, { params: { id } }: IdParams) => { +export const updateTask = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const data = UpdateTaskRequestSchema.parse(await req.json()) @@ -67,7 +69,8 @@ export const updateTask = async (req: NextRequest, { params: { id } }: IdParams) return NextResponse.json({ updatedTask }) } -export const deleteTask = async (req: NextRequest, { params: { id } }: IdParams) => { +export const deleteTask = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const tasksService = new TasksService(user) @@ -75,7 +78,8 @@ export const deleteTask = async (req: NextRequest, { params: { id } }: IdParams) return new NextResponse(null, { status: httpStatus.NO_CONTENT }) } -export const clientUpdateTask = async (req: NextRequest, { params: { id } }: IdParams) => { +export const clientUpdateTask = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const workflowStateId = req.nextUrl.searchParams.get('workflowStateId') const tasksService = new TasksService(user) @@ -83,7 +87,8 @@ export const clientUpdateTask = async (req: NextRequest, { params: { id } }: IdP return NextResponse.json({ updatedTask }) } -export const getTaskPath = async (req: NextRequest, { params: { id } }: IdParams) => { +export const getTaskPath = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const tasksService = new TasksService(user) const path = await tasksService.getTraversalPath(id) diff --git a/src/app/api/tasks/templates/public/public.controller.ts b/src/app/api/tasks/templates/public/public.controller.ts index 72e6e10d9..2d40b3213 100644 --- a/src/app/api/tasks/templates/public/public.controller.ts +++ b/src/app/api/tasks/templates/public/public.controller.ts @@ -22,7 +22,8 @@ export const getTaskTemplatesPublic = async (req: NextRequest) => { return NextResponse.json({ data: PublicTemplateSerializer.serialize(templates), nextToken: base64NextToken }) } -export const getTaskTemplatePublic = async (req: NextRequest, { params: { id } }: IdParams) => { +export const getTaskTemplatePublic = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const templatesService = new TemplatesService(user) const template = await templatesService.getOneTemplate(id) diff --git a/src/app/api/tasks/templates/templates.controller.ts b/src/app/api/tasks/templates/templates.controller.ts index f35a3fc93..64efe142e 100644 --- a/src/app/api/tasks/templates/templates.controller.ts +++ b/src/app/api/tasks/templates/templates.controller.ts @@ -24,7 +24,8 @@ export const createTaskTemplate = async (req: NextRequest) => { return NextResponse.json({ data }) } -export const updateTaskTemplate = async (req: NextRequest, { params: { id } }: IdParams) => { +export const updateTaskTemplate = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const payload = UpdateTemplateRequestSchema.parse(await req.json()) @@ -34,7 +35,8 @@ export const updateTaskTemplate = async (req: NextRequest, { params: { id } }: I return NextResponse.json({ data }) } -export const createSubTaskTemplate = async (req: NextRequest, { params: { id } }: IdParams) => { +export const createSubTaskTemplate = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const payload = CreateTemplateRequestSchema.parse(await req.json()) const templatesService = new TemplatesService(user) @@ -42,7 +44,8 @@ export const createSubTaskTemplate = async (req: NextRequest, { params: { id } } return NextResponse.json({ data }) } -export const deleteTaskTemplate = async (req: NextRequest, { params: { id } }: IdParams) => { +export const deleteTaskTemplate = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const templatesService = new TemplatesService(user) @@ -51,7 +54,8 @@ export const deleteTaskTemplate = async (req: NextRequest, { params: { id } }: I return new NextResponse(null, { status: httpStatus.NO_CONTENT }) } -export const applyTemplate = async (req: NextRequest, { params: { id } }: IdParams) => { +export const applyTemplate = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const templatesService = new TemplatesService(user) const data = await templatesService.getAppliedTemplateDescription(id) @@ -59,7 +63,8 @@ export const applyTemplate = async (req: NextRequest, { params: { id } }: IdPara return NextResponse.json({ data }) } -export const getOneTemplate = async (req: NextRequest, { params: { id } }: IdParams) => { +export const getOneTemplate = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const templatesService = new TemplatesService(user) const data = await templatesService.getOneTemplate(id) @@ -67,7 +72,8 @@ export const getOneTemplate = async (req: NextRequest, { params: { id } }: IdPar return NextResponse.json({ data }) } -export const getSubtemplates = async (req: NextRequest, { params: { id } }: IdParams) => { +export const getSubtemplates = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params const user = await authenticate(req) const templatesService = new TemplatesService(user) const data = await templatesService.getSubtemplates(id) diff --git a/src/app/client/page.tsx b/src/app/client/page.tsx index 0a5318bde..51ba540a7 100644 --- a/src/app/client/page.tsx +++ b/src/app/client/page.tsx @@ -54,7 +54,8 @@ async function getWorkspace(token: string): Promise { return await copilot.getWorkspace() } -export default async function ClientPage({ searchParams }: { searchParams: { token: string } & UrlActionParamsType }) { +export default async function ClientPage(props: { searchParams: Promise<{ token: string } & UrlActionParamsType> }) { + const searchParams = await props.searchParams const token = searchParams.token if (!z.string().safeParse(token).success) { return diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index c5c2e6155..aad383a9f 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -78,13 +78,12 @@ async function getTaskPath(token: string, taskId: string): Promise + searchParams: Promise<{ token: string; isRedirect?: string; fromNotificationCenter?: string }> }) { + const searchParams = await props.searchParams + const params = await props.params const { token } = searchParams const { task_id, user_type } = params diff --git a/src/app/detail/ui/TaskCardList.tsx b/src/app/detail/ui/TaskCardList.tsx index f87ace852..b89037623 100644 --- a/src/app/detail/ui/TaskCardList.tsx +++ b/src/app/detail/ui/TaskCardList.tsx @@ -88,6 +88,10 @@ export const TaskCardList = ({ const subtaskCount = useSubtaskCount(task.id) + const [assigneeValue, setAssigneeValue] = useState | undefined>(() => { + return assigneeCache[task.id] + }) //Omitting type for NoAssignee + useEffect(() => { if (!task) return @@ -165,10 +169,6 @@ export const TaskCardList = ({ return match ?? NoAssignee } - const [assigneeValue, setAssigneeValue] = useState | undefined>(() => { - return assigneeCache[task.id] - }) //Omitting type for NoAssignee - return ( { return templates.data } -export default async function TaskDetailPage({ - params, - searchParams, -}: { - params: { template_id: string } - searchParams: { token: string } +export default async function TaskDetailPage(props: { + params: Promise<{ template_id: string }> + searchParams: Promise<{ token: string }> }) { + const searchParams = await props.searchParams + const params = await props.params const { token } = searchParams const { template_id } = params diff --git a/src/app/manage-templates/page.tsx b/src/app/manage-templates/page.tsx index 65ac7ef67..45413f8ec 100644 --- a/src/app/manage-templates/page.tsx +++ b/src/app/manage-templates/page.tsx @@ -58,12 +58,13 @@ async function getWorkspace(token: string): Promise { } interface ManageTemplatesPageProps { - searchParams: { + searchParams: Promise<{ token: string - } + }> } -export default async function ManageTemplatesPage({ searchParams }: ManageTemplatesPageProps) { +export default async function ManageTemplatesPage(props: ManageTemplatesPageProps) { + const searchParams = await props.searchParams const { token } = searchParams const [workflowStates, assignee, templates, tokenPayload, workspace] = await Promise.all([ getAllWorkflowStates(token), diff --git a/src/app/notification-center/page.tsx b/src/app/notification-center/page.tsx index 85d2548be..e39b77851 100644 --- a/src/app/notification-center/page.tsx +++ b/src/app/notification-center/page.tsx @@ -14,7 +14,8 @@ async function getNotificationDetail(token: string) { return await copilot.getIUNotification(z.string().parse(tokenPayload.notificationId), tokenPayload.workspaceId) // notification "id" is expected in tokenPayload } -export default async function NotificationCenter({ searchParams }: { searchParams: { token: string } }) { +export default async function NotificationCenter(props: { searchParams: Promise<{ token: string }> }) { + const searchParams = await props.searchParams const token = searchParams.token if (!z.string().safeParse(token).success) { return diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index afc7fbe23..cf236a80c 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -120,6 +120,59 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => : null, ) + // this function handles the action param passed in the url and fill the values in the form + const handleUrlActionParam = useCallback(async () => { + if (urlActionParams.pf && token) { + const payload = JSON.parse(decodeURIComponent(urlActionParams.pf)) + + if (!payload.companyId && payload.clientId) { + const assigneeVal = assignee.find((val) => val.id === payload.clientId) + + if (!assigneeVal) { + setErrorMessage('Assignee not found') + delete payload.clientId + } else { + if (Array.isArray(assigneeVal.companyIds) && assigneeVal.companyIds.length === 1) { + payload.companyId = assigneeVal.companyIds[0] + } else if ( + assigneeVal.companyId && + (!assigneeVal.companyIds || (Array.isArray(assigneeVal.companyIds) && !assigneeVal.companyIds.length)) + ) { + payload.companyId = assigneeVal.companyId + } else if (Array.isArray(assigneeVal?.companyIds)) { + // If client has multiple companies, set error + delete payload.clientId + setErrorMessage('companyId must be provided for clients with more than one company') + } else { + delete payload.clientId + setErrorMessage('companyId must be provided when clientId is provided') + } + } + } + + // respect the filter Ids first. This is needed for CRM deep link for respective clients + const assigneeFilter = { + [UserIds.INTERNAL_USER_ID]: payload?.internalUserId || null, + [UserIds.CLIENT_ID]: payload?.clientId || null, + [UserIds.COMPANY_ID]: payload?.companyId || null, + } + + const taskPayload = { + title: payload?.name || '', + description: marked(payload?.description?.replaceAll('\n', '
') || '', { async: false }), + workflowStateId: workflowStates.find((state) => state.key === payload?.status)?.id || '', + dueDate: payload?.dueDate || null, + templateId: payload?.templateId || null, + userIds: assigneeFilter, + parentId: payload?.parentTaskId || null, + } + + setAssigneeValue(getSelectorAssigneeFromFilterOptions(assignee, assigneeFilter) || null) + setActionParamPayload(payload) + store.dispatch(setAllCreateTaskFields(taskPayload)) + } + }, [urlActionParams, assignee]) + useEffect(() => { if (!assignee.length) return if ( @@ -183,59 +236,6 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => } }, [handleClose]) - // this function handles the action param passed in the url and fill the values in the form - const handleUrlActionParam = useCallback(async () => { - if (urlActionParams.pf && token) { - const payload = JSON.parse(decodeURIComponent(urlActionParams.pf)) - - if (!payload.companyId && payload.clientId) { - const assigneeVal = assignee.find((val) => val.id === payload.clientId) - - if (!assigneeVal) { - setErrorMessage('Assignee not found') - delete payload.clientId - } else { - if (Array.isArray(assigneeVal.companyIds) && assigneeVal.companyIds.length === 1) { - payload.companyId = assigneeVal.companyIds[0] - } else if ( - assigneeVal.companyId && - (!assigneeVal.companyIds || (Array.isArray(assigneeVal.companyIds) && !assigneeVal.companyIds.length)) - ) { - payload.companyId = assigneeVal.companyId - } else if (Array.isArray(assigneeVal?.companyIds)) { - // If client has multiple companies, set error - delete payload.clientId - setErrorMessage('companyId must be provided for clients with more than one company') - } else { - delete payload.clientId - setErrorMessage('companyId must be provided when clientId is provided') - } - } - } - - // respect the filter Ids first. This is needed for CRM deep link for respective clients - const assigneeFilter = { - [UserIds.INTERNAL_USER_ID]: payload?.internalUserId || null, - [UserIds.CLIENT_ID]: payload?.clientId || null, - [UserIds.COMPANY_ID]: payload?.companyId || null, - } - - const taskPayload = { - title: payload?.name || '', - description: marked(payload?.description?.replaceAll('\n', '
') || '', { async: false }), - workflowStateId: workflowStates.find((state) => state.key === payload?.status)?.id || '', - dueDate: payload?.dueDate || null, - templateId: payload?.templateId || null, - userIds: assigneeFilter, - parentId: payload?.parentTaskId || null, - } - - setAssigneeValue(getSelectorAssigneeFromFilterOptions(assignee, assigneeFilter) || null) - setActionParamPayload(payload) - store.dispatch(setAllCreateTaskFields(taskPayload)) - } - }, [urlActionParams, assignee]) - const handleAssigneeChange = (inputValue: InputValue[]) => { // remove task viewers if assignee is cleared or changed to client or company if (inputValue.length === 0 || inputValue[0].object !== UserRole.IU) { diff --git a/src/app/ui/VirtualizedTasksLists.tsx b/src/app/ui/VirtualizedTasksLists.tsx index d5c27a937..3f1f31ebf 100644 --- a/src/app/ui/VirtualizedTasksLists.tsx +++ b/src/app/ui/VirtualizedTasksLists.tsx @@ -75,7 +75,9 @@ export function TasksRowVirtualizer({ rows, mode, token, subtasksByTaskId, workf
rowVirtualizer.measureElement(node)} + ref={(node) => { + rowVirtualizer.measureElement(node) + }} style={{ display: 'flex', position: 'absolute', diff --git a/src/components/atoms/CopilotTooltip.tsx b/src/components/atoms/CopilotTooltip.tsx index 02af20187..32e4466f7 100644 --- a/src/components/atoms/CopilotTooltip.tsx +++ b/src/components/atoms/CopilotTooltip.tsx @@ -4,7 +4,7 @@ import React, { useRef, useState } from 'react' export interface CopilotTooltipProps { content: React.ReactNode - children: React.ReactElement + children: React.ReactElement position?: 'top' | 'bottom' | 'left' | 'right' disabled?: boolean allowMaxWidth?: boolean diff --git a/src/components/atoms/Tooltip.tsx b/src/components/atoms/Tooltip.tsx index c25362a08..07ae181f1 100644 --- a/src/components/atoms/Tooltip.tsx +++ b/src/components/atoms/Tooltip.tsx @@ -3,7 +3,7 @@ import { ReactElement } from 'react' interface TooltipProps { title: string - children: ReactElement + children: ReactElement } const TooltipComponent = ({ title, children }: TooltipProps) => { diff --git a/src/components/cards/TaskCard.tsx b/src/components/cards/TaskCard.tsx index c359941ec..8a147385a 100644 --- a/src/components/cards/TaskCard.tsx +++ b/src/components/cards/TaskCard.tsx @@ -104,6 +104,10 @@ export const TaskCard = ({ task, href, workflowState, mode, subtasks, workflowDi const [selectedAssignee, setSelectedAssignee] = useState(undefined) + const [assigneeValue, setAssigneeValue] = useState | undefined>(() => { + return assigneeCache[task.id] + }) //Omitting type for NoAssignee + const { renderingItem: _statusValue, updateRenderingItem: updateStatusValue } = useHandleSelectorComponent({ // item: selectedWorkflowState, item: workflowState ?? task.workflowState, @@ -164,10 +168,6 @@ export const TaskCard = ({ task, href, workflowState, mode, subtasks, workflowDi return match ?? NoAssignee } - const [assigneeValue, setAssigneeValue] = useState | undefined>(() => { - return assigneeCache[task.id] - }) //Omitting type for NoAssignee - return ( diff --git a/src/components/inputs/CopilotSelector.tsx b/src/components/inputs/CopilotSelector.tsx index d58770ce5..9fff672c1 100644 --- a/src/components/inputs/CopilotSelector.tsx +++ b/src/components/inputs/CopilotSelector.tsx @@ -93,6 +93,10 @@ export const CopilotPopSelector = ({ const shouldCallOnChangeWithEmpty = useRef(false) const initialAssignee = initialValue && parseAssigneeToSelectorOptions(initialValue) + const [selectedAssignee, setSelectedAssignee] = useState( + initialAssignee && selectorOptionsToInputValue(initialAssignee), + ) + const handleClick = useCallback( (event: React.MouseEvent) => { event.preventDefault() @@ -106,9 +110,6 @@ export const CopilotPopSelector = ({ [disabled, initialAssignee], ) - const [selectedAssignee, setSelectedAssignee] = useState( - initialAssignee && selectorOptionsToInputValue(initialAssignee), - ) const handleClose = useCallback(() => { if (shouldCallOnChangeWithEmpty.current && selectedAssignee && !selectedAssignee.length) { onChange(selectedAssignee) diff --git a/src/components/inputs/Selector.tsx b/src/components/inputs/Selector.tsx index 354d6be2a..a7ab5639c 100644 --- a/src/components/inputs/Selector.tsx +++ b/src/components/inputs/Selector.tsx @@ -2,7 +2,7 @@ import { Box, Button, Popper, Stack, Typography } from '@mui/material' import { StyledAutocomplete } from '@/components/inputs/Autocomplete' import { statusIcons } from '@/utils/iconMatcher' import { useFocusableInput } from '@/hooks/useFocusableInput' -import { HTMLAttributes, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { HTMLAttributes, ReactNode, useCallback, useEffect, useMemo, useRef, useState, type JSX } from 'react' import { StyledTextField } from './TextField' import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto' import { IAssigneeCombined, Sizes, IExtraOption, ITemplate, UserTypesName } from '@/types/interfaces' @@ -144,7 +144,7 @@ export default function Selector({ } const [placement, setPlacement] = useState('bottom') - const timeoutRef = useRef() + const timeoutRef = useRef(undefined) const handlePlacementChange = useCallback((data: ModifierArguments) => { if (data.state?.placement && data.state.placement !== placement) { diff --git a/src/hoc/CustomScrollBar.tsx b/src/hoc/CustomScrollBar.tsx index 7151895e5..67baae539 100644 --- a/src/hoc/CustomScrollBar.tsx +++ b/src/hoc/CustomScrollBar.tsx @@ -25,8 +25,8 @@ export const CustomScrollBar: React.FC = ({ children, clas const [isScrollable, setIsScrollable] = useState(false) const [dragStartY, setDragStartY] = useState(0) const [startScrollTop, setStartScrollTop] = useState(0) - const scrollTimeoutRef = useRef() - const resizeObserverRef = useRef() + const scrollTimeoutRef = useRef(undefined) + const resizeObserverRef = useRef(undefined) const scrollHostRef = useRef(null) diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts index e32f4bd7e..ab225589b 100644 --- a/src/hooks/useClickOutside.ts +++ b/src/hooks/useClickOutside.ts @@ -21,7 +21,7 @@ function getEventPath(event: Event): EventTarget[] { * @param events - Event types to listen to (default: ["pointerdown", "touchstart"]). */ export default function useClickOutside( - refs: RefObject | RefObject[], + refs: RefObject | RefObject[], handler: (event: Event) => void, events: string[] = ['pointerdown', 'touchstart'], ): void { diff --git a/tsconfig.json b/tsconfig.json index 3b5d3b379..95b097dbb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "es6", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -19,12 +23,29 @@ } ], "paths": { - "@/*": ["./src/*"], - "@api/*": ["./src/app/api/*"], - "@cmd/*": ["./src/cmd/*"] + "@/*": [ + "./src/*" + ], + "@api/*": [ + "./src/app/api/*" + ], + "@cmd/*": [ + "./src/cmd/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "trigger.config.ts"], - "exclude": ["node_modules"], - "types": ["jest"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "trigger.config.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ], + "types": [ + "jest" + ] } diff --git a/yarn.lock b/yarn.lock index 9bb0c3c58..29c4971fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -53,6 +53,27 @@ json5 "^2.2.3" semver "^6.3.1" +"@babel/core@^7.24.4": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.5.tgz#4c81b35e51e1b734f510c99b07dfbc7bbbb48f7e" + integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.5" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-module-transforms" "^7.28.3" + "@babel/helpers" "^7.28.4" + "@babel/parser" "^7.28.5" + "@babel/template" "^7.27.2" + "@babel/traverse" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + "@babel/generator@^7.28.3", "@babel/generator@^7.7.2": version "7.28.3" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.3.tgz#9626c1741c650cbac39121694a0f2d7451b8ef3e" @@ -64,6 +85,17 @@ "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" +"@babel/generator@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.5.tgz#712722d5e50f44d07bc7ac9fe84438742dd61298" + integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== + dependencies: + "@babel/parser" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3": version "7.27.3" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5" @@ -193,6 +225,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + "@babel/helper-validator-option@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" @@ -222,6 +259,13 @@ dependencies: "@babel/types" "^7.28.4" +"@babel/parser@^7.24.4", "@babel/parser@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" + integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== + dependencies: + "@babel/types" "^7.28.5" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz#61dd8a8e61f7eb568268d1b5f129da3eee364bf9" @@ -983,6 +1027,19 @@ "@babel/types" "^7.28.4" debug "^4.3.1" +"@babel/traverse@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.5.tgz#450cab9135d21a7a2ca9d2d35aa05c20e68c360b" + integrity sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.5" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.5" + "@babel/template" "^7.27.2" + "@babel/types" "^7.28.5" + debug "^4.3.1" + "@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.21.3", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.3.3", "@babel/types@^7.4.4": version "7.28.4" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a" @@ -991,6 +1048,14 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" +"@babel/types@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" + integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1067,6 +1132,13 @@ dependencies: tslib "^2.4.0" +"@emnapi/runtime@^1.7.0": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.7.1.tgz#a73784e23f5d57287369c808197288b52276b791" + integrity sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA== + dependencies: + tslib "^2.4.0" + "@emnapi/wasi-threads@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" @@ -1328,37 +1400,73 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== -"@eslint-community/eslint-utils@^4.2.0": +"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0": version "4.9.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g== dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.6.1": - version "4.12.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" - integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== -"@eslint/eslintrc@^2.1.4": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" - integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== +"@eslint/config-array@^0.21.1": + version "0.21.1" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713" + integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== + dependencies: + "@eslint/object-schema" "^2.1.7" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/config-helpers@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz#1bd006ceeb7e2e55b2b773ab318d300e1a66aeda" + integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== + dependencies: + "@eslint/core" "^0.17.0" + +"@eslint/core@^0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.17.0.tgz#77225820413d9617509da9342190a2019e78761c" + integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.1", "@eslint/eslintrc@^3.3.3": + version "3.3.3" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz#26393a0806501b5e2b6a43aa588a4d8df67880ac" + integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.6.0" - globals "^13.19.0" + espree "^10.0.1" + globals "^14.0.0" ignore "^5.2.0" import-fresh "^3.2.1" - js-yaml "^4.1.0" + js-yaml "^4.1.1" minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.57.1": - version "8.57.1" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" - integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== +"@eslint/js@9.39.2": + version "9.39.2" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.2.tgz#2d4b8ec4c3ea13c1b3748e0c97ecd766bdd80599" + integrity sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA== + +"@eslint/object-schema@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad" + integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== + +"@eslint/plugin-kit@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz#9779e3fd9b7ee33571a57435cf4335a1794a6cb2" + integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== + dependencies: + "@eslint/core" "^0.17.0" + levn "^0.4.1" "@faker-js/faker@^8.4.1": version "8.4.1" @@ -1400,19 +1508,165 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== dependencies: - "@humanwhocodes/object-schema" "^2.0.3" - debug "^4.3.1" - minimatch "^3.0.5" + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/object-schema@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" - integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@img/colour@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@img/colour/-/colour-1.0.0.tgz#d2fabb223455a793bf3bf9c70de3d28526aa8311" + integrity sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw== + +"@img/sharp-darwin-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz#6e0732dcade126b6670af7aa17060b926835ea86" + integrity sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w== + optionalDependencies: + "@img/sharp-libvips-darwin-arm64" "1.2.4" + +"@img/sharp-darwin-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz#19bc1dd6eba6d5a96283498b9c9f401180ee9c7b" + integrity sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw== + optionalDependencies: + "@img/sharp-libvips-darwin-x64" "1.2.4" + +"@img/sharp-libvips-darwin-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz#2894c0cb87d42276c3889942e8e2db517a492c43" + integrity sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g== + +"@img/sharp-libvips-darwin-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz#e63681f4539a94af9cd17246ed8881734386f8cc" + integrity sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg== + +"@img/sharp-libvips-linux-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz#b1b288b36864b3bce545ad91fa6dadcf1a4ad318" + integrity sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw== + +"@img/sharp-libvips-linux-arm@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz#b9260dd1ebe6f9e3bdbcbdcac9d2ac125f35852d" + integrity sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A== + +"@img/sharp-libvips-linux-ppc64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz#4b83ecf2a829057222b38848c7b022e7b4d07aa7" + integrity sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA== + +"@img/sharp-libvips-linux-riscv64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz#880b4678009e5a2080af192332b00b0aaf8a48de" + integrity sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA== + +"@img/sharp-libvips-linux-s390x@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz#74f343c8e10fad821b38f75ced30488939dc59ec" + integrity sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ== + +"@img/sharp-libvips-linux-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz#df4183e8bd8410f7d61b66859a35edeab0a531ce" + integrity sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw== + +"@img/sharp-libvips-linuxmusl-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz#c8d6b48211df67137541007ee8d1b7b1f8ca8e06" + integrity sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw== + +"@img/sharp-libvips-linuxmusl-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz#be11c75bee5b080cbee31a153a8779448f919f75" + integrity sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg== + +"@img/sharp-linux-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz#7aa7764ef9c001f15e610546d42fce56911790cc" + integrity sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg== + optionalDependencies: + "@img/sharp-libvips-linux-arm64" "1.2.4" + +"@img/sharp-linux-arm@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz#5fb0c3695dd12522d39c3ff7a6bc816461780a0d" + integrity sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw== + optionalDependencies: + "@img/sharp-libvips-linux-arm" "1.2.4" + +"@img/sharp-linux-ppc64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz#9c213a81520a20caf66978f3d4c07456ff2e0813" + integrity sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA== + optionalDependencies: + "@img/sharp-libvips-linux-ppc64" "1.2.4" + +"@img/sharp-linux-riscv64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz#cdd28182774eadbe04f62675a16aabbccb833f60" + integrity sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw== + optionalDependencies: + "@img/sharp-libvips-linux-riscv64" "1.2.4" + +"@img/sharp-linux-s390x@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz#93eac601b9f329bb27917e0e19098c722d630df7" + integrity sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg== + optionalDependencies: + "@img/sharp-libvips-linux-s390x" "1.2.4" + +"@img/sharp-linux-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz#55abc7cd754ffca5002b6c2b719abdfc846819a8" + integrity sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ== + optionalDependencies: + "@img/sharp-libvips-linux-x64" "1.2.4" + +"@img/sharp-linuxmusl-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz#d6515ee971bb62f73001a4829b9d865a11b77086" + integrity sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64" "1.2.4" + +"@img/sharp-linuxmusl-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz#d97978aec7c5212f999714f2f5b736457e12ee9f" + integrity sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.2.4" + +"@img/sharp-wasm32@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz#2f15803aa626f8c59dd7c9d0bbc766f1ab52cfa0" + integrity sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw== + dependencies: + "@emnapi/runtime" "^1.7.0" + +"@img/sharp-win32-arm64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz#3706e9e3ac35fddfc1c87f94e849f1b75307ce0a" + integrity sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g== + +"@img/sharp-win32-ia32@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz#0b71166599b049e032f085fb9263e02f4e4788de" + integrity sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg== + +"@img/sharp-win32-x64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz#a81ffb00e69267cd0a1d626eaedb8a8430b2b2f8" + integrity sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw== "@isaacs/cliui@^8.0.2": version "8.0.2" @@ -1827,107 +2081,102 @@ resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.32.tgz#6d1107e2b7cc8649ff3730b8b46deb4e8a6d38fa" integrity sha512-n9mQdigI6iZ/DF6pCTwMKeWgF2e8lg7qgt5M7HXMLtyhZYMnf/u905M18sSpPmHL9MKp9JHo56C6jrD2EvWxng== -"@next/env@14.2.35": - version "14.2.35" - resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.35.tgz#e979016d0ca8500a47d41ffd02625fe29b8df35a" - integrity sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ== +"@next/env@16.1.1": + version "16.1.1" + resolved "https://registry.yarnpkg.com/@next/env/-/env-16.1.1.tgz#3d06a470efff135746ef609cc02a4996512bd9ab" + integrity sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA== -"@next/eslint-plugin-next@14.0.4": - version "14.0.4" - resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-14.0.4.tgz#474fd88d92209270021186043513fbdc4203f5ec" - integrity sha512-U3qMNHmEZoVmHA0j/57nRfi3AscXNvkOnxDmle/69Jz/G0o/gWjXTDdlgILZdrxQ0Lw/jv2mPW8PGy0EGIHXhQ== +"@next/eslint-plugin-next@16.1.1": + version "16.1.1" + resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.1.tgz#544e1ca4775287e2369f7db6b8e81c752f35035f" + integrity sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw== dependencies: - glob "7.1.7" + fast-glob "3.3.1" "@next/swc-darwin-arm64@14.2.32": version "14.2.32" resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.32.tgz#83482a7282df899b73d916e02b02a189771e706c" integrity sha512-osHXveM70zC+ilfuFa/2W6a1XQxJTvEhzEycnjUaVE8kpUS09lDpiDDX2YLdyFCzoUbvbo5r0X1Kp4MllIOShw== -"@next/swc-darwin-arm64@14.2.33": - version "14.2.33" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz#9e74a4223f1e5e39ca4f9f85709e0d95b869b298" - integrity sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA== +"@next/swc-darwin-arm64@16.1.1": + version "16.1.1" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz#1e7e87fd21fcabce546dfb04fb946ecd9f866917" + integrity sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA== "@next/swc-darwin-x64@14.2.32": version "14.2.32" resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.32.tgz#1a9eb676a014e1fc999251f10288c25a0f81d6d1" integrity sha512-P9NpCAJuOiaHHpqtrCNncjqtSBi1f6QUdHK/+dNabBIXB2RUFWL19TY1Hkhu74OvyNQEYEzzMJCMQk5agjw1Qg== -"@next/swc-darwin-x64@14.2.33": - version "14.2.33" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz#fcf0c45938da9b0cc2ec86357d6aefca90bd17f3" - integrity sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA== +"@next/swc-darwin-x64@16.1.1": + version "16.1.1" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz#6617d03b96bdffad7bf4df50d4a699faea0d04c3" + integrity sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw== "@next/swc-linux-arm64-gnu@14.2.32": version "14.2.32" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.32.tgz#7713a49abd555d6f698e766b1631b67d881b4ee4" integrity sha512-v7JaO0oXXt6d+cFjrrKqYnR2ubrD+JYP7nQVRZgeo5uNE5hkCpWnHmXm9vy3g6foMO8SPwL0P3MPw1c+BjbAzA== -"@next/swc-linux-arm64-gnu@14.2.33": - version "14.2.33" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz#837f91a740eb4420c06f34c4677645315479d9be" - integrity sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw== +"@next/swc-linux-arm64-gnu@16.1.1": + version "16.1.1" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz#d8337e2f4881a80221a1f56ac3f979b665b7e574" + integrity sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ== "@next/swc-linux-arm64-musl@14.2.32": version "14.2.32" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.32.tgz#327efdffe97e56f5389a7889cdedbd676fdbb519" integrity sha512-tA6sIKShXtSJBTH88i0DRd6I9n3ZTirmwpwAqH5zdJoQF7/wlJXR8DkPmKwYl5mFWhEKr5IIa3LfpMW9RRwKmQ== -"@next/swc-linux-arm64-musl@14.2.33": - version "14.2.33" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz#dc8903469e5c887b25e3c2217a048bd30c58d3d4" - integrity sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg== +"@next/swc-linux-arm64-musl@16.1.1": + version "16.1.1" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz#6c24392824406a50f27fb9cf4e49362d4666db1c" + integrity sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg== "@next/swc-linux-x64-gnu@14.2.32": version "14.2.32" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.32.tgz#a3e7444613d0fe5c8ea4ead08d6a9c818246758c" integrity sha512-7S1GY4TdnlGVIdeXXKQdDkfDysoIVFMD0lJuVVMeb3eoVjrknQ0JNN7wFlhCvea0hEk0Sd4D1hedVChDKfV2jw== -"@next/swc-linux-x64-gnu@14.2.33": - version "14.2.33" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz#344438be592b6b28cc540194274561e41f9933e5" - integrity sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg== +"@next/swc-linux-x64-gnu@16.1.1": + version "16.1.1" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz#f1bd19fc7d119f27c4cf7f51915aef0f119c943f" + integrity sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ== "@next/swc-linux-x64-musl@14.2.32": version "14.2.32" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.32.tgz#a2ec5b0a06c740d6740c938b1d4a614f1a13f018" integrity sha512-OHHC81P4tirVa6Awk6eCQ6RBfWl8HpFsZtfEkMpJ5GjPsJ3nhPe6wKAJUZ/piC8sszUkAgv3fLflgzPStIwfWg== -"@next/swc-linux-x64-musl@14.2.33": - version "14.2.33" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz#3379fad5e0181000b2a4fac0b80f7ca4ffe795c8" - integrity sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA== +"@next/swc-linux-x64-musl@16.1.1": + version "16.1.1" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz#20c1ea0c98988c337614ce6fda01b82300ff00fd" + integrity sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA== "@next/swc-win32-arm64-msvc@14.2.32": version "14.2.32" resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.32.tgz#b4d3e47c6b276fc4711deb978d04015d029d198d" integrity sha512-rORQjXsAFeX6TLYJrCG5yoIDj+NKq31Rqwn8Wpn/bkPNy5rTHvOXkW8mLFonItS7QC6M+1JIIcLe+vOCTOYpvg== -"@next/swc-win32-arm64-msvc@14.2.33": - version "14.2.33" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz#bca8f4dde34656aef8e99f1e5696de255c2f00e5" - integrity sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ== +"@next/swc-win32-arm64-msvc@16.1.1": + version "16.1.1" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz#9d66be9dc4ebc458d445a7f6ee804f416d5c2daf" + integrity sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA== "@next/swc-win32-ia32-msvc@14.2.32": version "14.2.32" resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.32.tgz#d1f1f854a1fbbaeefa8f81271437448653f33494" integrity sha512-jHUeDPVHrgFltqoAqDB6g6OStNnFxnc7Aks3p0KE0FbwAvRg6qWKYF5mSTdCTxA3axoSAUwxYdILzXJfUwlHhA== -"@next/swc-win32-ia32-msvc@14.2.33": - version "14.2.33" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz#a69c581483ea51dd3b8907ce33bb101fe07ec1df" - integrity sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q== - "@next/swc-win32-x64-msvc@14.2.32": version "14.2.32" resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.32.tgz#8212d681cf6858a9e3204728f8f2b161000683ed" integrity sha512-2N0lSoU4GjfLSO50wvKpMQgKd4HdI2UHEhQPPPnlgfBJlOgJxkjpkYBqzk08f1gItBB6xF/n+ykso2hgxuydsA== -"@next/swc-win32-x64-msvc@14.2.33": - version "14.2.33" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz#f1a40062530c17c35a86d8c430b3ae465eb7cea1" - integrity sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg== +"@next/swc-win32-x64-msvc@16.1.1": + version "16.1.1" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz#96e9e335e2577481dab6fc7a2af48f3e4a28fbdb" + integrity sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw== "@ngrok/ngrok-android-arm64@1.5.2": version "1.5.2" @@ -2026,7 +2275,7 @@ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== -"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": +"@nodelib/fs.walk@^1.2.3": version "1.2.8" resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== @@ -3251,6 +3500,13 @@ resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== +"@swc/helpers@0.5.15": + version "0.5.15" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7" + integrity sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g== + dependencies: + tslib "^2.8.0" + "@swc/helpers@0.5.5": version "0.5.5" resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.5.tgz#12689df71bfc9b21c4f4ca00ae55f2f16c8b77c0" @@ -3417,7 +3673,7 @@ resolved "https://registry.yarnpkg.com/@types/deep-equal/-/deep-equal-1.0.4.tgz#c0a854be62d6b9fae665137a6639aab53389a147" integrity sha512-tqdiS4otQP4KmY0PR3u6KbZ5EWvhNdUoS/jc93UuK23C220lOZ/9TvjfxdPcKvqwwDVtmtSCrnr0p/2dirAxkA== -"@types/estree@*", "@types/estree@^1.0.0": +"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -3470,6 +3726,11 @@ "@types/tough-cookie" "*" parse5 "^7.0.0" +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" @@ -3559,7 +3820,7 @@ resolved "https://registry.yarnpkg.com/@types/phoenix/-/phoenix-1.6.6.tgz#3c1ab53fd5a23634b8e37ea72ccacbf07fbc7816" integrity sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A== -"@types/prop-types@*", "@types/prop-types@^15.7.12", "@types/prop-types@^15.7.15": +"@types/prop-types@^15.7.12", "@types/prop-types@^15.7.15": version "15.7.15" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== @@ -3571,30 +3832,22 @@ dependencies: "@types/react" "*" -"@types/react-dom@^18": - version "18.3.7" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.7.tgz#b89ddf2cd83b4feafcc4e2ea41afdfb95a0d194f" - integrity sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ== +"@types/react-dom@19.2.3": + version "19.2.3" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" + integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== "@types/react-transition-group@^4.4.0", "@types/react-transition-group@^4.4.10", "@types/react-transition-group@^4.4.11": version "4.4.12" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@*": - version "19.1.13" - resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.13.tgz#fc650ffa680d739a25a530f5d7ebe00cdd771883" - integrity sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ== - dependencies: - csstype "^3.0.2" - -"@types/react@^18": - version "18.3.24" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.24.tgz#f6a5a4c613242dfe3af0dcee2b4ec47b92d9b6bd" - integrity sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A== +"@types/react@*", "@types/react@19.2.7": + version "19.2.7" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.7.tgz#84e62c0f23e8e4e5ac2cadcea1ffeacccae7f62f" + integrity sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg== dependencies: - "@types/prop-types" "*" - csstype "^3.0.2" + csstype "^3.2.2" "@types/retry@0.12.2": version "0.12.2" @@ -3652,56 +3905,101 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/parser@^5.4.2 || ^6.0.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.21.0.tgz#af8fcf66feee2edc86bc5d1cf45e33b0630bf35b" - integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ== +"@typescript-eslint/eslint-plugin@8.51.0": + version "8.51.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz#8985230730c0d955bf6aa0aed98c5c2c95102e1a" + integrity sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "8.51.0" + "@typescript-eslint/type-utils" "8.51.0" + "@typescript-eslint/utils" "8.51.0" + "@typescript-eslint/visitor-keys" "8.51.0" + ignore "^7.0.0" + natural-compare "^1.4.0" + ts-api-utils "^2.2.0" + +"@typescript-eslint/parser@8.51.0": + version "8.51.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.51.0.tgz#584fb8be3a867cbf980917aabed5f7528f615d6b" + integrity sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A== dependencies: - "@typescript-eslint/scope-manager" "6.21.0" - "@typescript-eslint/types" "6.21.0" - "@typescript-eslint/typescript-estree" "6.21.0" - "@typescript-eslint/visitor-keys" "6.21.0" + "@typescript-eslint/scope-manager" "8.51.0" + "@typescript-eslint/types" "8.51.0" + "@typescript-eslint/typescript-estree" "8.51.0" + "@typescript-eslint/visitor-keys" "8.51.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz#ea8a9bfc8f1504a6ac5d59a6df308d3a0630a2b1" - integrity sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg== +"@typescript-eslint/project-service@8.51.0": + version "8.51.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.51.0.tgz#3cfef313d8bebbf4b2442675a4dd463cef4c8369" + integrity sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ== dependencies: - "@typescript-eslint/types" "6.21.0" - "@typescript-eslint/visitor-keys" "6.21.0" + "@typescript-eslint/tsconfig-utils" "^8.51.0" + "@typescript-eslint/types" "^8.51.0" + debug "^4.3.4" -"@typescript-eslint/types@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d" - integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg== +"@typescript-eslint/scope-manager@8.51.0": + version "8.51.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz#19b42f65680c21f7b6f40fe9024327f6bb1893c1" + integrity sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA== + dependencies: + "@typescript-eslint/types" "8.51.0" + "@typescript-eslint/visitor-keys" "8.51.0" -"@typescript-eslint/typescript-estree@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz#c47ae7901db3b8bddc3ecd73daff2d0895688c46" - integrity sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ== +"@typescript-eslint/tsconfig-utils@8.51.0", "@typescript-eslint/tsconfig-utils@^8.51.0": + version "8.51.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz#a575e9885e62dbd260fb64474eff1dae6e317515" + integrity sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw== + +"@typescript-eslint/type-utils@8.51.0": + version "8.51.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz#ec165b0312a6025c2a2a3f39641e46ab4f049564" + integrity sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q== dependencies: - "@typescript-eslint/types" "6.21.0" - "@typescript-eslint/visitor-keys" "6.21.0" + "@typescript-eslint/types" "8.51.0" + "@typescript-eslint/typescript-estree" "8.51.0" + "@typescript-eslint/utils" "8.51.0" debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - minimatch "9.0.3" - semver "^7.5.4" - ts-api-utils "^1.0.1" + ts-api-utils "^2.2.0" + +"@typescript-eslint/types@8.51.0", "@typescript-eslint/types@^8.51.0": + version "8.51.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.51.0.tgz#6996e59d49e92fb893531bdc249f0d92a7bebdbb" + integrity sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag== + +"@typescript-eslint/typescript-estree@8.51.0": + version "8.51.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz#b57f5157d1ac2127bd7c2c9ad8060fa017df4a1a" + integrity sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng== + dependencies: + "@typescript-eslint/project-service" "8.51.0" + "@typescript-eslint/tsconfig-utils" "8.51.0" + "@typescript-eslint/types" "8.51.0" + "@typescript-eslint/visitor-keys" "8.51.0" + debug "^4.3.4" + minimatch "^9.0.4" + semver "^7.6.0" + tinyglobby "^0.2.15" + ts-api-utils "^2.2.0" -"@typescript-eslint/visitor-keys@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz#87a99d077aa507e20e238b11d56cc26ade45fe47" - integrity sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A== +"@typescript-eslint/utils@8.51.0": + version "8.51.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.51.0.tgz#b9a071cd210647f860a38873acf9bc5157bea56a" + integrity sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA== dependencies: - "@typescript-eslint/types" "6.21.0" - eslint-visitor-keys "^3.4.1" + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/scope-manager" "8.51.0" + "@typescript-eslint/types" "8.51.0" + "@typescript-eslint/typescript-estree" "8.51.0" -"@ungap/structured-clone@^1.2.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" - integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== +"@typescript-eslint/visitor-keys@8.51.0": + version "8.51.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz#d37f5c82b9bece2c8aeb3ba7bb836bbba0f92bb8" + integrity sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg== + dependencies: + "@typescript-eslint/types" "8.51.0" + eslint-visitor-keys "^4.2.1" "@unrs/resolver-binding-android-arm-eabi@1.11.1": version "1.11.1" @@ -3839,7 +4137,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.14.0, acorn@^8.15.0, acorn@^8.8.1, acorn@^8.9.0: +acorn@^8.14.0, acorn@^8.15.0, acorn@^8.8.1: version "8.15.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== @@ -3967,11 +4265,6 @@ array-includes@^3.1.6, array-includes@^3.1.8, array-includes@^3.1.9: is-string "^1.1.1" math-intrinsics "^1.1.0" -array-union@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" - integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== - array.prototype.findlast@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz#3e4fbcb30a15a7f5bf64cf2faae22d139c2e4904" @@ -4674,7 +4967,7 @@ cronstrue@^2.21.0: resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.61.0.tgz#97c79c77045c052afb44cb9f5f8eaf54398094f2" integrity sha512-ootN5bvXbIQI9rW94+QsXN5eROtXWwew6NkdGxIRpS/UFWRggL0G5Al7a9GTBFEsuvVhJ2K3CntIIVt7L2ILhA== -cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -4754,6 +5047,11 @@ csstype@3.1.3, csstype@^3.0.2, csstype@^3.1.3: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + cva@^1.0.0-beta.3: version "1.0.0-beta.4" resolved "https://registry.yarnpkg.com/cva/-/cva-1.0.0-beta.4.tgz#3feb8b403a1774110eb34e2c409cb0b7c7fbe243" @@ -4959,6 +5257,11 @@ detect-libc@^2.0.0: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.0.tgz#3ca811f60a7b504b0480e5008adacc660b0b8c4f" integrity sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg== +detect-libc@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -4974,13 +5277,6 @@ diff-sequences@^29.6.3: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== - dependencies: - path-type "^4.0.0" - dlv@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" @@ -5002,13 +5298,6 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - dom-helpers@^5.0.1: version "5.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" @@ -5350,20 +5639,20 @@ escape-string-regexp@^4.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== -eslint-config-next@14.0.4: - version "14.0.4" - resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-14.0.4.tgz#7cd2c0a3b310203d41cf0dbf9d31f9b0a6235b4a" - integrity sha512-9/xbOHEQOmQtqvQ1UsTQZpnA7SlDMBtuKJ//S4JnoyK3oGLhILKXdBgu/UO7lQo/2xOykQULS1qQ6p2+EpHgAQ== +eslint-config-next@16.1.1: + version "16.1.1" + resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-16.1.1.tgz#536ae0016ac2454e27d625775e0942ff46037116" + integrity sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA== dependencies: - "@next/eslint-plugin-next" "14.0.4" - "@rushstack/eslint-patch" "^1.3.3" - "@typescript-eslint/parser" "^5.4.2 || ^6.0.0" + "@next/eslint-plugin-next" "16.1.1" eslint-import-resolver-node "^0.3.6" eslint-import-resolver-typescript "^3.5.2" - eslint-plugin-import "^2.28.1" - eslint-plugin-jsx-a11y "^6.7.1" - eslint-plugin-react "^7.33.2" - eslint-plugin-react-hooks "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + eslint-plugin-import "^2.32.0" + eslint-plugin-jsx-a11y "^6.10.0" + eslint-plugin-react "^7.37.0" + eslint-plugin-react-hooks "^7.0.0" + globals "16.4.0" + typescript-eslint "^8.46.0" eslint-import-resolver-node@^0.3.6, eslint-import-resolver-node@^0.3.9: version "0.3.9" @@ -5394,7 +5683,7 @@ eslint-module-utils@^2.12.1: dependencies: debug "^3.2.7" -eslint-plugin-import@^2.28.1: +eslint-plugin-import@^2.32.0: version "2.32.0" resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz#602b55faa6e4caeaa5e970c198b5c00a37708980" integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== @@ -5419,7 +5708,7 @@ eslint-plugin-import@^2.28.1: string.prototype.trimend "^1.0.9" tsconfig-paths "^3.15.0" -eslint-plugin-jsx-a11y@^6.7.1: +eslint-plugin-jsx-a11y@^6.10.0: version "6.10.2" resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz#d2812bb23bf1ab4665f1718ea442e8372e638483" integrity sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q== @@ -5440,12 +5729,18 @@ eslint-plugin-jsx-a11y@^6.7.1: safe-regex-test "^1.0.3" string.prototype.includes "^2.0.1" -"eslint-plugin-react-hooks@^4.5.0 || 5.0.0-canary-7118f5dd7-20230705": - version "5.0.0-canary-7118f5dd7-20230705" - resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz#4d55c50e186f1a2b0636433d2b0b2f592ddbccfd" - integrity sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw== +eslint-plugin-react-hooks@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz#66e258db58ece50723ef20cc159f8aa908219169" + integrity sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA== + dependencies: + "@babel/core" "^7.24.4" + "@babel/parser" "^7.24.4" + hermes-parser "^0.25.1" + zod "^3.25.0 || ^4.0.0" + zod-validation-error "^3.5.0 || ^4.0.0" -eslint-plugin-react@^7.33.2: +eslint-plugin-react@^7.37.0: version "7.37.5" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz#2975511472bdda1b272b34d779335c9b0e877065" integrity sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA== @@ -5469,78 +5764,79 @@ eslint-plugin-react@^7.33.2: string.prototype.matchall "^4.0.12" string.prototype.repeat "^1.0.0" -eslint-scope@^7.2.2: - version "7.2.2" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" - integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== dependencies: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: +eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint@^8: - version "8.57.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" - integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== - dependencies: - "@eslint-community/eslint-utils" "^4.2.0" - "@eslint-community/regexpp" "^4.6.1" - "@eslint/eslintrc" "^2.1.4" - "@eslint/js" "8.57.1" - "@humanwhocodes/config-array" "^0.13.0" +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint@^9: + version "9.39.2" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.2.tgz#cb60e6d16ab234c0f8369a3fe7cc87967faf4b6c" + integrity sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.21.1" + "@eslint/config-helpers" "^0.4.2" + "@eslint/core" "^0.17.0" + "@eslint/eslintrc" "^3.3.1" + "@eslint/js" "9.39.2" + "@eslint/plugin-kit" "^0.4.1" + "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" - "@nodelib/fs.walk" "^1.2.8" - "@ungap/structured-clone" "^1.2.0" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" ajv "^6.12.4" chalk "^4.0.0" - cross-spawn "^7.0.2" + cross-spawn "^7.0.6" debug "^4.3.2" - doctrine "^3.0.0" escape-string-regexp "^4.0.0" - eslint-scope "^7.2.2" - eslint-visitor-keys "^3.4.3" - espree "^9.6.1" - esquery "^1.4.2" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" + esquery "^1.5.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" + file-entry-cache "^8.0.0" find-up "^5.0.0" glob-parent "^6.0.2" - globals "^13.19.0" - graphemer "^1.4.0" ignore "^5.2.0" imurmurhash "^0.1.4" is-glob "^4.0.0" - is-path-inside "^3.0.3" - js-yaml "^4.1.0" json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" lodash.merge "^4.6.2" minimatch "^3.1.2" natural-compare "^1.4.0" optionator "^0.9.3" - strip-ansi "^6.0.1" - text-table "^0.2.0" -espree@^9.6.0, espree@^9.6.1: - version "9.6.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" - integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== +espree@^10.0.1, espree@^10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== dependencies: - acorn "^8.9.0" + acorn "^8.15.0" acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.4.1" + eslint-visitor-keys "^4.2.1" esprima@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.4.2: +esquery@^1.5.0: version "1.6.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== @@ -5658,7 +5954,18 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.2.9, fast-glob@^3.3.2: +fast-glob@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4" + integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-glob@^3.3.2: version "3.3.3" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== @@ -5711,12 +6018,12 @@ fetch-retry@^6.0.0: resolved "https://registry.yarnpkg.com/fetch-retry/-/fetch-retry-6.0.0.tgz#4ffdf92c834d72ae819e42a4ee2a63f1e9454426" integrity sha512-BUFj1aMubgib37I3v4q78fYo63Po7t4HUPTpQ6/QE6yK6cIQrP+W43FYToeTEyg5m2Y7eFUtijUuAv/PDlWuag== -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== dependencies: - flat-cache "^3.0.4" + flat-cache "^4.0.0" file-saver@^2.0.5: version "2.0.5" @@ -5751,14 +6058,13 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -flat-cache@^3.0.4: - version "3.2.0" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" - integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== dependencies: flatted "^3.2.9" - keyv "^4.5.3" - rimraf "^3.0.2" + keyv "^4.5.4" flatted@^3.2.9: version "3.3.3" @@ -5941,18 +6247,6 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob@7.1.7: - version "7.1.7" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" - integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - glob@^10.3.10: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" @@ -5987,12 +6281,15 @@ glob@^9.3.2: minipass "^4.2.4" path-scurry "^1.6.1" -globals@^13.19.0: - version "13.24.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" - integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== - dependencies: - type-fest "^0.20.2" +globals@16.4.0: + version "16.4.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-16.4.0.tgz#574bc7e72993d40cf27cf6c241f324ee77808e51" + integrity sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw== + +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== globalthis@^1.0.4: version "1.0.4" @@ -6002,18 +6299,6 @@ globalthis@^1.0.4: define-properties "^1.2.1" gopd "^1.0.1" -globby@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" - integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.2.9" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^3.0.0" - gopd@^1.0.1, gopd@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" @@ -6024,11 +6309,6 @@ graceful-fs@^4.2.11, graceful-fs@^4.2.9: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== -graphemer@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" - integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== - has-bigints@^1.0.2: version "1.1.0" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" @@ -6077,6 +6357,18 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hermes-estree@0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.25.1.tgz#6aeec17d1983b4eabf69721f3aa3eb705b17f480" + integrity sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw== + +hermes-parser@^0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.25.1.tgz#5be0e487b2090886c62bd8a11724cd766d5f54d1" + integrity sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA== + dependencies: + hermes-estree "0.25.1" + hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -6167,6 +6459,11 @@ ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== +ignore@^7.0.0: + version "7.0.5" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + immediate@~3.0.5: version "3.0.6" resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" @@ -6415,11 +6712,6 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-path-inside@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - is-plain-object@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" @@ -7015,6 +7307,13 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + dependencies: + argparse "^2.0.1" + jsdom@^25.0.1: version "25.0.1" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-25.0.1.tgz#536ec685c288fc8a5773a65f82d8b44badcc73ef" @@ -7099,7 +7398,7 @@ json5@^2.2.3: object.assign "^4.1.4" object.values "^1.1.6" -keyv@^4.5.3: +keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== @@ -7189,7 +7488,7 @@ long@^5.0.0: resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -7288,7 +7587,7 @@ merge-stream@^2.0.0: resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== -merge2@^1.3.0, merge2@^1.4.1: +merge2@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== @@ -7333,14 +7632,7 @@ minimal-polyfills@^2.2.3: resolved "https://registry.yarnpkg.com/minimal-polyfills/-/minimal-polyfills-2.2.3.tgz#22af58de16807b325f29b83ca38ffb83e75ec3f4" integrity sha512-oxdmJ9cL+xV72h0xYxp4tP2d5/fTBpP45H8DIOn9pASuF8a3IYTf+25fMGDYGiWW+MFsuog6KD6nfmhZJQ+uUw== -minimatch@9.0.3: - version "9.0.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" - integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== - dependencies: - brace-expansion "^2.0.1" - -minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -7462,23 +7754,22 @@ next@14.2.35: resolved "https://registry.yarnpkg.com/next/-/next-14.2.35.tgz#7c68873a15fe5a19401f2f993fea535be3366ee9" integrity sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig== dependencies: - "@next/env" "14.2.35" - "@swc/helpers" "0.5.5" - busboy "1.6.0" + "@next/env" "16.1.1" + "@swc/helpers" "0.5.15" + baseline-browser-mapping "^2.8.3" caniuse-lite "^1.0.30001579" - graceful-fs "^4.2.11" postcss "8.4.31" - styled-jsx "5.1.1" + styled-jsx "5.1.6" optionalDependencies: - "@next/swc-darwin-arm64" "14.2.33" - "@next/swc-darwin-x64" "14.2.33" - "@next/swc-linux-arm64-gnu" "14.2.33" - "@next/swc-linux-arm64-musl" "14.2.33" - "@next/swc-linux-x64-gnu" "14.2.33" - "@next/swc-linux-x64-musl" "14.2.33" - "@next/swc-win32-arm64-msvc" "14.2.33" - "@next/swc-win32-ia32-msvc" "14.2.33" - "@next/swc-win32-x64-msvc" "14.2.33" + "@next/swc-darwin-arm64" "16.1.1" + "@next/swc-darwin-x64" "16.1.1" + "@next/swc-linux-arm64-gnu" "16.1.1" + "@next/swc-linux-arm64-musl" "16.1.1" + "@next/swc-linux-x64-gnu" "16.1.1" + "@next/swc-linux-x64-musl" "16.1.1" + "@next/swc-win32-arm64-msvc" "16.1.1" + "@next/swc-win32-x64-msvc" "16.1.1" + sharp "^0.34.4" next@^14.0.2: version "14.2.32" @@ -8267,13 +8558,12 @@ react-dnd@^16.0.1: fast-deep-equal "^3.1.3" hoist-non-react-statics "^3.3.2" -react-dom@^18.3: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" - integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== +react-dom@19.2.3: + version "19.2.3" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17" + integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg== dependencies: - loose-envify "^1.1.0" - scheduler "^0.23.2" + scheduler "^0.27.0" react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" @@ -8342,12 +8632,10 @@ react-zoom-pan-pinch@^3.7.0: resolved "https://registry.yarnpkg.com/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz#7db4d2ec49c316eb20f71c56e9012eeb3ef4b504" integrity sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA== -react@^18: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" - integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== - dependencies: - loose-envify "^1.1.0" +react@19.2.3: + version "19.2.3" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8" + integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA== read-cache@^1.0.0: version "1.0.0" @@ -8549,13 +8837,6 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - rollup@3.29.5: version "3.29.5" resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.5.tgz#8a2e477a758b520fb78daf04bca4c522c1da8a54" @@ -8637,12 +8918,10 @@ saxes@^6.0.0: dependencies: xmlchars "^2.2.0" -scheduler@^0.23.2: - version "0.23.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" - integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== - dependencies: - loose-envify "^1.1.0" +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== semver@^6.3.0, semver@^6.3.1: version "6.3.1" @@ -8654,6 +8933,11 @@ semver@^7.3.5, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.7.1: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== +semver@^7.6.0, semver@^7.7.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + server-only@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/server-only/-/server-only-0.0.1.tgz#0f366bb6afb618c37c9255a314535dc412cd1c9e" @@ -8695,6 +8979,40 @@ shallowequal@1.1.0: resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== +sharp@^0.34.4: + version "0.34.5" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.34.5.tgz#b6f148e4b8c61f1797bde11a9d1cfebbae2c57b0" + integrity sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg== + dependencies: + "@img/colour" "^1.0.0" + detect-libc "^2.1.2" + semver "^7.7.3" + optionalDependencies: + "@img/sharp-darwin-arm64" "0.34.5" + "@img/sharp-darwin-x64" "0.34.5" + "@img/sharp-libvips-darwin-arm64" "1.2.4" + "@img/sharp-libvips-darwin-x64" "1.2.4" + "@img/sharp-libvips-linux-arm" "1.2.4" + "@img/sharp-libvips-linux-arm64" "1.2.4" + "@img/sharp-libvips-linux-ppc64" "1.2.4" + "@img/sharp-libvips-linux-riscv64" "1.2.4" + "@img/sharp-libvips-linux-s390x" "1.2.4" + "@img/sharp-libvips-linux-x64" "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64" "1.2.4" + "@img/sharp-libvips-linuxmusl-x64" "1.2.4" + "@img/sharp-linux-arm" "0.34.5" + "@img/sharp-linux-arm64" "0.34.5" + "@img/sharp-linux-ppc64" "0.34.5" + "@img/sharp-linux-riscv64" "0.34.5" + "@img/sharp-linux-s390x" "0.34.5" + "@img/sharp-linux-x64" "0.34.5" + "@img/sharp-linuxmusl-arm64" "0.34.5" + "@img/sharp-linuxmusl-x64" "0.34.5" + "@img/sharp-wasm32" "0.34.5" + "@img/sharp-win32-arm64" "0.34.5" + "@img/sharp-win32-ia32" "0.34.5" + "@img/sharp-win32-x64" "0.34.5" + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -9093,6 +9411,13 @@ styled-jsx@5.1.1: dependencies: client-only "0.0.1" +styled-jsx@5.1.6: + version "5.1.6" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.6.tgz#83b90c077e6c6a80f7f5e8781d0f311b2fe41499" + integrity sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA== + dependencies: + client-only "0.0.1" + stylis@4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" @@ -9223,10 +9548,10 @@ tailwindcss@^3.3.0: resolve "^1.22.8" sucrase "^3.35.0" -tapwrite@1.1.93: - version "1.1.93" - resolved "https://registry.yarnpkg.com/tapwrite/-/tapwrite-1.1.93.tgz#ea7a8d7dd2272a8651dd5b1260dcddebbf3ece26" - integrity sha512-yrXN2I8egnQKgGUNP7Kp7aFZUTZrT0I14Pkxpe+hWNzA17RC1zNcahUzX8hcBf9ytZNRankxk4Q0bjUXW+X9kw== +tapwrite@1.1.94: + version "1.1.94" + resolved "https://registry.yarnpkg.com/tapwrite/-/tapwrite-1.1.94.tgz#e0f72d262139f5679b7c6a142456756ce6db4aa2" + integrity sha512-KtGbpKq6Ya47s7WPi6DN0uYQZCgGGpWYeyhwzbhkL2LlrsumUnLWjK5+o3OH8truHwZNJmyxlsDWY26iLAncXg== dependencies: re-resizable "^6.10.0" tippy.js "^6.3.7" @@ -9280,11 +9605,6 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" @@ -9377,10 +9697,10 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== -ts-api-utils@^1.0.1: - version "1.4.3" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064" - integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw== +ts-api-utils@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.3.0.tgz#9f397ac9d88ac76e8dd6e8bc4af0dbf98af99f73" + integrity sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg== ts-interface-checker@^0.1.9: version "0.1.13" @@ -9412,7 +9732,7 @@ tslib@2.6.2: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== -tslib@^2.0.3, tslib@^2.4.0: +tslib@^2.0.3, tslib@^2.4.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -9446,11 +9766,6 @@ type-detect@4.0.8: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - type-fest@^0.21.3: version "0.21.3" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" @@ -9506,6 +9821,16 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" +typescript-eslint@^8.46.0: + version "8.51.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.51.0.tgz#3cf2ead3d7c5adf940fbac213c043370c0c000b2" + integrity sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA== + dependencies: + "@typescript-eslint/eslint-plugin" "8.51.0" + "@typescript-eslint/parser" "8.51.0" + "@typescript-eslint/typescript-estree" "8.51.0" + "@typescript-eslint/utils" "8.51.0" + typescript@^5: version "5.9.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6" @@ -9982,3 +10307,8 @@ zod@3.25.76, zod@^3.20.2, zod@^3.23.8: version "3.25.76" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== + +"zod@^3.25.0 || ^4.0.0": + version "4.2.1" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.2.1.tgz#07f0388c7edbfd5f5a2466181cb4adf5b5dbd57b" + integrity sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw== From d0a0ced11d88759b8ab863f12cc98d4331d9414d Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 30 Dec 2025 15:21:35 +0545 Subject: [PATCH 03/81] feat(OUT-2844): upgrade next version to 16 --- eslint.config.mjs | 20 ++++++++++++++++++++ tsconfig.json | 35 +++++++---------------------------- 2 files changed, 27 insertions(+), 28 deletions(-) create mode 100644 eslint.config.mjs diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 000000000..313d8853a --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from 'eslint/config' +import nextCoreWebVitals from 'eslint-config-next/core-web-vitals' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export default defineConfig([ + { + extends: [...nextCoreWebVitals], + rules: { + 'react-hooks/set-state-in-effect': 'off', + 'react-hooks/refs': 'off', + 'react-hooks/preserve-manual-memoization': 'off', + 'react-compiler/react-compiler': 'off', + 'react-hooks/static-components': 'off', + }, + }, +]) diff --git a/tsconfig.json b/tsconfig.json index 95b097dbb..ea6b22f9d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "es6", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -23,29 +19,12 @@ } ], "paths": { - "@/*": [ - "./src/*" - ], - "@api/*": [ - "./src/app/api/*" - ], - "@cmd/*": [ - "./src/cmd/*" - ] + "@/*": ["./src/*"], + "@api/*": ["./src/app/api/*"], + "@cmd/*": ["./src/cmd/*"] } }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - "trigger.config.ts", - ".next/dev/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ], - "types": [ - "jest" - ] + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "trigger.config.ts", ".next/dev/types/**/*.ts"], + "exclude": ["node_modules"], + "types": ["jest"] } From 653741ae8cc86474fe351b38a09c6ebec783d64b Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 30 Dec 2025 15:33:23 +0545 Subject: [PATCH 04/81] feat(OUT-2844): upgrade next version to 16 --- src/app/detail/ui/TaskEditor.tsx | 2 +- src/app/manage-templates/ui/TemplateDetails.tsx | 2 +- src/app/manage-templates/ui/TemplateForm.tsx | 2 +- src/app/ui/NewTaskForm.tsx | 2 +- src/components/cards/CommentCard.tsx | 2 +- src/components/cards/ReplyCard.tsx | 2 +- src/components/inputs/ReplyInput.tsx | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/detail/ui/TaskEditor.tsx b/src/app/detail/ui/TaskEditor.tsx index 4682f12cd..edca9e837 100644 --- a/src/app/detail/ui/TaskEditor.tsx +++ b/src/app/detail/ui/TaskEditor.tsx @@ -193,7 +193,7 @@ export const TaskEditor = ({ uploadFn={uploadFn} handleImageDoubleClick={handleImagePreview} deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', task_id, null)} - attachmentLayout={AttachmentLayout} + attachmentLayout={(props) => } addAttachmentButton maxUploadLimit={MAX_UPLOAD_LIMIT} /> diff --git a/src/app/manage-templates/ui/TemplateDetails.tsx b/src/app/manage-templates/ui/TemplateDetails.tsx index bd4487931..aa3fe5103 100644 --- a/src/app/manage-templates/ui/TemplateDetails.tsx +++ b/src/app/manage-templates/ui/TemplateDetails.tsx @@ -164,7 +164,7 @@ export default function TemplateDetails({ uploadFn={uploadFn} handleImageDoubleClick={handleImagePreview} deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', template_id, null)} - attachmentLayout={AttachmentLayout} + attachmentLayout={(props) => } addAttachmentButton maxUploadLimit={MAX_UPLOAD_LIMIT} /> diff --git a/src/app/manage-templates/ui/TemplateForm.tsx b/src/app/manage-templates/ui/TemplateForm.tsx index eb465fa5b..ac902a706 100644 --- a/src/app/manage-templates/ui/TemplateForm.tsx +++ b/src/app/manage-templates/ui/TemplateForm.tsx @@ -166,7 +166,7 @@ const NewTemplateFormInputs = () => { targetMethod == TargetMethod.POST ? null : targetTemplateId, ) } - attachmentLayout={AttachmentLayout} + attachmentLayout={(props) => } maxUploadLimit={MAX_UPLOAD_LIMIT} parentContainerStyle={{ gap: '0px', minHeight: '60px' }} /> diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index cf236a80c..2b99cd1e3 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -619,7 +619,7 @@ const NewTaskFormInputs = ({ isEditorReadonly }: NewTaskFormInputsProps) => { uploadFn={uploadFn} readonly={isEditorReadonly} deleteEditorAttachments={(url) => deleteEditorAttachmentsHandler(url, token ?? '', null, null)} - attachmentLayout={AttachmentLayout} + attachmentLayout={(props) => } maxUploadLimit={MAX_UPLOAD_LIMIT} parentContainerStyle={{ gap: '0px', minHeight: '60px' }} /> diff --git a/src/components/cards/CommentCard.tsx b/src/components/cards/CommentCard.tsx index c056f9f54..8b26ac56c 100644 --- a/src/components/cards/CommentCard.tsx +++ b/src/components/cards/CommentCard.tsx @@ -65,7 +65,7 @@ export const CommentCard = ({ const [replies, setReplies] = useState(comment.details.replies as ReplyResponse[]) const [timeAgo, setTimeAgo] = useState(getTimeDifference(comment.createdAt)) const [isReadOnly, setIsReadOnly] = useState(true) - const editRef = useRef(null) + const editRef = useRef(document.createElement('div')) const [focusReplyInput, setFocusedReplyInput] = useState(false) const [showConfirmDeleteModal, setShowConfirmDeleteModal] = useState(false) diff --git a/src/components/cards/ReplyCard.tsx b/src/components/cards/ReplyCard.tsx index 22a6f9a70..5f7418151 100644 --- a/src/components/cards/ReplyCard.tsx +++ b/src/components/cards/ReplyCard.tsx @@ -55,7 +55,7 @@ export const ReplyCard = ({ const [editedContent, setEditedContent] = useState(content) const [isListOrMenuActive, setIsListOrMenuActive] = useState(false) const [isFocused, setIsFocused] = useState(false) - const editRef = useRef(null) + const editRef = useRef(document.createElement('div')) const canEdit = tokenPayload?.internalUserId == item?.initiatorId || tokenPayload?.clientId == item?.initiatorId diff --git a/src/components/inputs/ReplyInput.tsx b/src/components/inputs/ReplyInput.tsx index 32944c09c..33d31628c 100644 --- a/src/components/inputs/ReplyInput.tsx +++ b/src/components/inputs/ReplyInput.tsx @@ -98,7 +98,7 @@ export const ReplyInput = ({ setIsUploading(uploading) } - const editorRef = useRef(null) + const editorRef = useRef(document.createElement('div')) const [isMultiline, setIsMultiline] = useState(false) useEffect(() => { From a91fe7dedd81c59f6fbe45626bd782ffd14e3387 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 30 Dec 2025 15:38:35 +0545 Subject: [PATCH 05/81] feat(OUT-2844): upgrade next version to 16 --- src/components/inputs/FilterSelector/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/inputs/FilterSelector/index.tsx b/src/components/inputs/FilterSelector/index.tsx index 7b0e32718..4a987357a 100644 --- a/src/components/inputs/FilterSelector/index.tsx +++ b/src/components/inputs/FilterSelector/index.tsx @@ -90,7 +90,7 @@ export const FilterSelector = ({ disabled }: FilterSelectorProps) => { ({ color: theme.color.text.textSecondary, - fontSize: theme.typography.bodySm, + fontSize: '13px', lineHeight: '21px', })} > From e5b75395c7c776cce102f9e70a1ce52cde5690ee Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 30 Dec 2025 15:48:11 +0545 Subject: [PATCH 06/81] fix(OUT-2844): stylish js text-table dependency issue --- src/lib/stylish.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/stylish.js b/src/lib/stylish.js index 240af6c4b..039741e0b 100644 --- a/src/lib/stylish.js +++ b/src/lib/stylish.js @@ -5,7 +5,7 @@ 'use strict' const chalk = require('chalk') -const table = require('text-table') +const table = require('text-table')() // --- Custom implementation of stripAnsi --- // (The way that the default stripAnsi.js package is used requires a dynamic import From 31290a6d78fa8fdda2381fb8184ae9843458f91b Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 30 Dec 2025 15:52:48 +0545 Subject: [PATCH 07/81] feat(OUT-2844): upgrade next version to 16 --- mcp.json | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 mcp.json diff --git a/mcp.json b/mcp.json deleted file mode 100644 index ee5d245dc..000000000 --- a/mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "next-devtools": { - "command": "npx", - "args": ["-y", "next-devtools-mcp@latest"] - } - } -} From 0d9715d8a48783e8200eba40adaa62b6ca2aad1d Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 30 Dec 2025 15:56:38 +0545 Subject: [PATCH 08/81] feat(OUT-2844): upgrade next version to 16 --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/package.json b/package.json index e771ed30c..1af40c850 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "open": "^10.1.0", "prettier": "^3.1.1", "tailwindcss": "^3.3.0", + "text-table": "^0.2.0", "tsx": "^4.16.5", "typescript": "^5" }, diff --git a/yarn.lock b/yarn.lock index 29c4971fe..046abcd26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9605,6 +9605,11 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" From 135f14daa33a090f718f2e5311b962da7ed1746f Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 30 Dec 2025 16:01:25 +0545 Subject: [PATCH 09/81] feat(OUT-2844): upgrade next version to 16 --- package.json | 1 - yarn.lock | 5 ----- 2 files changed, 6 deletions(-) diff --git a/package.json b/package.json index 1af40c850..e771ed30c 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,6 @@ "open": "^10.1.0", "prettier": "^3.1.1", "tailwindcss": "^3.3.0", - "text-table": "^0.2.0", "tsx": "^4.16.5", "typescript": "^5" }, diff --git a/yarn.lock b/yarn.lock index 046abcd26..29c4971fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9605,11 +9605,6 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" From a59b647d73626d83b6cb8458af6fb9a76305bd3e Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 30 Dec 2025 16:06:46 +0545 Subject: [PATCH 10/81] fix(OUT-2844): added text-table as dev dependency to fix linting issue on stylish.js --- package.json | 1 + src/lib/stylish.js | 2 +- yarn.lock | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e771ed30c..1af40c850 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "open": "^10.1.0", "prettier": "^3.1.1", "tailwindcss": "^3.3.0", + "text-table": "^0.2.0", "tsx": "^4.16.5", "typescript": "^5" }, diff --git a/src/lib/stylish.js b/src/lib/stylish.js index 039741e0b..240af6c4b 100644 --- a/src/lib/stylish.js +++ b/src/lib/stylish.js @@ -5,7 +5,7 @@ 'use strict' const chalk = require('chalk') -const table = require('text-table')() +const table = require('text-table') // --- Custom implementation of stripAnsi --- // (The way that the default stripAnsi.js package is used requires a dynamic import diff --git a/yarn.lock b/yarn.lock index 29c4971fe..046abcd26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9605,6 +9605,11 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" From c36179d79f9e1c14fa942231bd8b6dbef502f091 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 30 Dec 2025 16:48:45 +0545 Subject: [PATCH 11/81] fix(OUT-2844): added react-hooks/static-components rule and fixed related violations --- eslint.config.mjs | 1 - src/components/atoms/TaskTitle.tsx | 10 +++++----- src/components/inputs/Selector.tsx | 27 +++++++++++++++------------ 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 313d8853a..1f72dcaf8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,7 +14,6 @@ export default defineConfig([ 'react-hooks/refs': 'off', 'react-hooks/preserve-manual-memoization': 'off', 'react-compiler/react-compiler': 'off', - 'react-hooks/static-components': 'off', }, }, ]) diff --git a/src/components/atoms/TaskTitle.tsx b/src/components/atoms/TaskTitle.tsx index cad5b8761..3a6a135ce 100644 --- a/src/components/atoms/TaskTitle.tsx +++ b/src/components/atoms/TaskTitle.tsx @@ -57,13 +57,9 @@ const TaskTitle = ({ title, variant = 'board', isClient = false }: TaskTitleProp ) - const TaskTitleComponent = () => { - return {title} - } - return isOverflowing ? ( - } allowMaxWidth={true}> + } allowMaxWidth={true}> {typographyComponent} @@ -72,4 +68,8 @@ const TaskTitle = ({ title, variant = 'board', isClient = false }: TaskTitleProp ) } +const TaskTitleComponent = ({ title }: { title?: string }) => { + return {title} +} + export default TaskTitle diff --git a/src/components/inputs/Selector.tsx b/src/components/inputs/Selector.tsx index a7ab5639c..dab67dd74 100644 --- a/src/components/inputs/Selector.tsx +++ b/src/components/inputs/Selector.tsx @@ -201,18 +201,6 @@ export default function Selector({ } } - const ListWithEndOption = React.forwardRef((props, ref) => { - const { ...other } = props - - return ( - - {props.children} - - ) - }) - - ListWithEndOption.displayName = 'ListWithEndOption' - return ( @@ -562,3 +550,18 @@ const AssigneeSelectorRenderer = ({ props, option }: { props: HTMLAttributes ) } + +const ListWithEndOption = React.forwardRef< + HTMLDivElement, + JSX.IntrinsicElements['div'] & { endOption?: React.ReactNode; endOptionHref?: string } +>((props, ref) => { + const { endOption, endOptionHref, ...other } = props + + return ( + + {props.children} + + ) +}) + +ListWithEndOption.displayName = 'ListWithEndOption' From ae6ad91bd7fa2f4b599e5fe3453794154ea7bc92 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 31 Dec 2025 12:23:20 +0545 Subject: [PATCH 12/81] fix(OUT-2844): reverted turning off reactCompiler in next config --- next.config.js | 1 - 1 file changed, 1 deletion(-) diff --git a/next.config.js b/next.config.js index 8f98216ae..244e21663 100644 --- a/next.config.js +++ b/next.config.js @@ -4,7 +4,6 @@ const { withSentryConfig } = require('@sentry/nextjs') const nextConfig = { cacheMaxMemorySize: 0, experimental: { - reactCompiler: false, staleTimes: { dynamic: 0, static: 0, From 6e74af57298e9f6026be5cfc73dff61f07fd9913 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 12 Jan 2026 16:29:29 +0545 Subject: [PATCH 13/81] fix(OUT-2844): fixed nested link tags, caching and removed customScrollBar component from details page --- src/app/_fetchers/OneTaskDataFetcher.tsx | 4 +- src/app/detail/[task_id]/[user_type]/page.tsx | 96 +++++++++---------- src/components/cards/TaskCard.tsx | 40 +++++--- yarn.lock | 33 ++++--- 4 files changed, 92 insertions(+), 81 deletions(-) diff --git a/src/app/_fetchers/OneTaskDataFetcher.tsx b/src/app/_fetchers/OneTaskDataFetcher.tsx index 852c8faa3..c8f6c3b6b 100644 --- a/src/app/_fetchers/OneTaskDataFetcher.tsx +++ b/src/app/_fetchers/OneTaskDataFetcher.tsx @@ -15,7 +15,6 @@ interface OneTaskDataFetcherProps extends PropsWithToken { } export const OneTaskDataFetcher = ({ token, task_id, initialTask }: OneTaskDataFetcherProps & PropsWithToken) => { - const hasDispatchedRef = useRef(false) const buildQueryString = (token: string) => { const queryParams = new URLSearchParams({ token }) @@ -30,7 +29,7 @@ export const OneTaskDataFetcher = ({ token, task_id, initialTask }: OneTaskDataF }) useEffect(() => { - if (!hasDispatchedRef.current && data?.task) { + if (data?.task) { //only invalidate cache on mount. const newTask = structuredClone(data.task) if (initialTask?.body && newTask.body === undefined) { @@ -44,7 +43,6 @@ export const OneTaskDataFetcher = ({ token, task_id, initialTask }: OneTaskDataF } } store.dispatch(setActiveTask(newTask)) - hasDispatchedRef.current = true } }, [data]) diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index aad383a9f..f4fc414ce 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -143,7 +143,7 @@ export default async function TaskDetailPage(props: { - + {isPreviewMode ? ( @@ -170,55 +170,53 @@ export default async function TaskDetailPage(props: { )} - - - - - { - 'use server' - await updateTaskDetail({ token, taskId: task_id, payload: { body: detail } }) - }} - updateTaskTitle={async (title) => { - 'use server' - title.trim() != '' && (await updateTaskDetail({ token, taskId: task_id, payload: { title } })) - }} - deleteTask={async () => { - 'use server' - await deleteTask(token, task_id) - }} - postAttachment={async (postAttachmentPayload) => { - 'use server' - await postAttachment(token, postAttachmentPayload) - }} - deleteAttachment={async (id: string) => { - 'use server' - await deleteAttachment(token, id) - }} - userType={params.user_type} - token={token} - /> - - {subTaskStatus.canCreateSubtask && ( - - )} + + + + { + 'use server' + await updateTaskDetail({ token, taskId: task_id, payload: { body: detail } }) + }} + updateTaskTitle={async (title) => { + 'use server' + title.trim() != '' && (await updateTaskDetail({ token, taskId: task_id, payload: { title } })) + }} + deleteTask={async () => { + 'use server' + await deleteTask(token, task_id) + }} + postAttachment={async (postAttachmentPayload) => { + 'use server' + await postAttachment(token, postAttachmentPayload) + }} + deleteAttachment={async (id: string) => { + 'use server' + await deleteAttachment(token, id) + }} + userType={params.user_type} + token={token} + /> + + {subTaskStatus.canCreateSubtask && ( + + )} - - - + + ({ border: `1px solid ${theme.color.borders.border}`, @@ -103,6 +104,7 @@ export const TaskCard = ({ task, href, workflowState, mode, subtasks, workflowDi const [currentDueDate, setCurrentDueDate] = useState(task.dueDate) const [selectedAssignee, setSelectedAssignee] = useState(undefined) + const router = useRouter() const [assigneeValue, setAssigneeValue] = useState | undefined>(() => { return assigneeCache[task.id] @@ -298,22 +300,30 @@ export const TaskCard = ({ task, href, workflowState, mode, subtasks, workflowDi {showSubtasks && subtasks && subtasks.length > 0 && ( {subtasks.map((subtask) => { + const href = `${getCardHref(subtask, mode)}/?token=${token}` return ( - - theme.color.gray[150], - }, - }} - > - - - + theme.color.gray[150], + }, + }} + onMouseEnter={(e) => { + e.preventDefault() + router.prefetch(href) + }} + onClick={(e) => { + e.preventDefault() + router.push(href) + }} //removed customLink and applied manual prefetch for now. todo: prevent nested Links in TaskCard. + > + + ) })} diff --git a/yarn.lock b/yarn.lock index 046abcd26..36af531a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1503,10 +1503,15 @@ resolved "https://registry.yarnpkg.com/@google-cloud/precise-date/-/precise-date-4.0.0.tgz#e179893a3ad628b17a6fabdfcc9d468753aac11a" integrity sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA== -"@humanwhocodes/config-array@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" - integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== dependencies: "@humanfs/core" "^0.19.1" "@humanwhocodes/retry" "^0.4.0" @@ -3066,11 +3071,6 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@rushstack/eslint-patch@^1.3.3": - version "1.12.0" - resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz#326a7b46f6d4cfa54ae25bb888551697873069b4" - integrity sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw== - "@s2-dev/streamstore@0.17.3": version "0.17.3" resolved "https://registry.yarnpkg.com/@s2-dev/streamstore/-/streamstore-0.17.3.tgz#6db097b2c63850d6fa3dce4fc5be829f596747d3" @@ -7749,10 +7749,10 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -next@14.2.35: - version "14.2.35" - resolved "https://registry.yarnpkg.com/next/-/next-14.2.35.tgz#7c68873a15fe5a19401f2f993fea535be3366ee9" - integrity sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig== +next@16.1.1: + version "16.1.1" + resolved "https://registry.yarnpkg.com/next/-/next-16.1.1.tgz#4cc3477daa0fe22678532ff4778baee95842d866" + integrity sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w== dependencies: "@next/env" "16.1.1" "@swc/helpers" "0.5.15" @@ -9639,7 +9639,7 @@ tinyexec@^1.0.1: resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251" integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== -tinyglobby@^0.2.13, tinyglobby@^0.2.2: +tinyglobby@^0.2.13, tinyglobby@^0.2.15, tinyglobby@^0.2.2: version "0.2.15" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== @@ -10308,6 +10308,11 @@ zod-validation-error@^1.5.0: resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-1.5.0.tgz#2b355007a1c3b7fb04fa476bfad4e7b3fd5491e3" integrity sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw== +"zod-validation-error@^3.5.0 || ^4.0.0": + version "4.0.2" + resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918" + integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ== + zod@3.25.76, zod@^3.20.2, zod@^3.23.8: version "3.25.76" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" From a616debd33319ce22354d9d5e24e1536f66c0f8a Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 5 Jan 2026 16:40:17 +0545 Subject: [PATCH 14/81] fix(OUT-2848): refactored tempalte realtime pattern. - created a separate RealtimeTemplatesHandler class with all the methods to entertain templates realtime logic. - cleaned the RealtimeTemplates HOC to only points to relevant methods in the RealtimeTempaltesHandler class. --- src/hoc/RealtimeTemplates.tsx | 96 +++++---------------------- src/lib/realtimeTemplates.ts | 119 ++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 81 deletions(-) create mode 100644 src/lib/realtimeTemplates.ts diff --git a/src/hoc/RealtimeTemplates.tsx b/src/hoc/RealtimeTemplates.tsx index c0c83d148..69877138c 100644 --- a/src/hoc/RealtimeTemplates.tsx +++ b/src/hoc/RealtimeTemplates.tsx @@ -1,5 +1,6 @@ 'use client' +import { RealtimeTemplatesHandler } from '@/lib/realtimeTemplates' import { supabase } from '@/lib/supabase' import { selectCreateTemplate, setActiveTemplate, setTemplates } from '@/redux/features/templateSlice' import store from '@/redux/store' @@ -28,37 +29,11 @@ export const RealTimeTemplates = ({ tokenPayload: Token token: string }) => { - const { templates = [], activeTemplate } = useSelector(selectCreateTemplate) + const { templates = [] } = useSelector(selectCreateTemplate) const pathname = usePathname() const router = useRouter() - const applySubtemplateToParentTemplate = (newTemplate: RealTimeTemplateResponse) => { - if (!newTemplate?.parentId) return - - if (activeTemplate?.id === newTemplate.parentId) { - store.dispatch( - setActiveTemplate({ - ...activeTemplate, - subTaskTemplates: [...(activeTemplate.subTaskTemplates || []), newTemplate], - }), - ) - } - - store.dispatch( - setTemplates( - templates.map((template) => - template.id === newTemplate.parentId - ? { - ...template, - subTaskTemplates: [...(template.subTaskTemplates || []), newTemplate], - } - : template, - ), - ), - ) //also append the subTaskTemplates to parent template on the templates store. - } - const redirectBack = (updatedTemplate: RealTimeTemplateResponse) => { //disable board navigation if not in template details page if (!pathname.includes(`manage-templates/${updatedTemplate.id}`)) return @@ -74,64 +49,23 @@ export const RealTimeTemplates = ({ if (isTemplatePayloadEqual(payload)) { return //no changes for the same payload } + + const realtimeHandler = new RealtimeTemplatesHandler(payload, redirectBack, tokenPayload) + const isSubTemplate = + Object.keys(payload.new).includes('parentId') && (payload.new as RealTimeTemplateResponse).parentId !== null + + if (isSubTemplate) { + return realtimeHandler.handleRealtimeSubTemplates() + } + if (payload.eventType === 'INSERT') { - const newTemplate = getFormattedTemplate(payload.new) - let canUserAccessTask = newTemplate.workspaceId === tokenPayload.workspaceId - if (!canUserAccessTask) return - if (newTemplate?.parentId) { - applySubtemplateToParentTemplate(newTemplate) - return - } - templates - ? store.dispatch(setTemplates([{ ...newTemplate }, ...templates])) - : store.dispatch(setTemplates([{ ...newTemplate }])) + return realtimeHandler.handleRealtimeTemplateInsert() } if (payload.eventType === 'UPDATE') { - const updatedTemplate = getFormattedTemplate(payload.new) - - const oldTemplate = templates && templates.find((template) => template.id == updatedTemplate.id) - if (payload.new.workspaceId === tokenPayload.workspaceId) { - if (updatedTemplate.deletedAt) { - const newTemplateArr = templates && templates.filter((el) => el.id !== updatedTemplate.id) - store.dispatch(setTemplates(newTemplateArr)) - redirectBack(updatedTemplate) - } else { - // Address Postgres' 8kb pagesize limitation (See TOAST https://www.postgresql.org/docs/current/storage-toast.html) - // If `body` field (which can be larger than pagesize) is not changed, Supabase Realtime won't send large fields like this in `payload.new` - - // So, we need to check if the oldTask has valid body but new body field is not being sent in updatedTask, and add it if required - if (oldTemplate?.body && updatedTemplate.body === undefined) { - updatedTemplate.body = oldTemplate?.body - } - if (oldTemplate && oldTemplate.body && updatedTemplate.body) { - const oldImgSrcs = extractImgSrcs(oldTemplate.body) - const newImgSrcs = extractImgSrcs(updatedTemplate.body) - // Need to extract new image Srcs and replace it with old ones, because since we are creating a new url of images on each task details navigation, - // a second user navigating the task details will generate a new src and replace it in the database which causes the previous user to load the src again(because its new) - if (oldImgSrcs.length > 0 && newImgSrcs.length > 0) { - updatedTemplate.body = replaceImgSrcs(updatedTemplate.body, newImgSrcs, oldImgSrcs) - } - } - if (activeTemplate?.id == updatedTemplate.id) { - store.dispatch( - setActiveTemplate({ - ...updatedTemplate, - subTaskTemplates: activeTemplate.subTaskTemplates, - }), - ) - } - if (updatedTemplate?.parentId) { - applySubtemplateToParentTemplate(updatedTemplate) - return - } - const newTemplateArr = [ - updatedTemplate, - ...(templates?.filter((template) => template.id !== updatedTemplate.id) || []), - ] - store.dispatch(setTemplates(newTemplateArr)) - } - } + return realtimeHandler.handleRealtimeTemplateUpdate() } + + console.error('Unknown event type for realtime handler') } useEffect(() => { diff --git a/src/lib/realtimeTemplates.ts b/src/lib/realtimeTemplates.ts new file mode 100644 index 000000000..5a1c217f5 --- /dev/null +++ b/src/lib/realtimeTemplates.ts @@ -0,0 +1,119 @@ +'use client' + +import { RealTimeTemplateResponse } from '@/hoc/RealtimeTemplates' +import { selectCreateTemplate, setActiveTemplate, setTemplates } from '@/redux/features/templateSlice' +import store from '@/redux/store' +import { Token } from '@/types/common' +import { AssigneeType, IAssigneeCombined } from '@/types/interfaces' +import { getFormattedTemplate } from '@/utils/getFormattedRealTimeData' +import { extractImgSrcs, replaceImgSrcs } from '@/utils/signedUrlReplacer' +import { RealtimePostgresChangesPayload } from '@supabase/supabase-js' + +export class RealtimeTemplatesHandler { + constructor( + private readonly payload: RealtimePostgresChangesPayload, + private readonly redirectToTemplateBoard: (newTemplate: RealTimeTemplateResponse) => void, + private readonly tokenPayload: Token, + ) { + const newTemplate = getFormattedTemplate(this.payload.new) + if (newTemplate.workspaceId !== this.tokenPayload.workspaceId) { + console.error('Realtime event ignored for template with different workspaceId') + return + } + } + + handleRealtimeTemplateInsert() { + const currentState = store.getState() + const { templates } = selectCreateTemplate(currentState) + const newTemplate = getFormattedTemplate(this.payload.new) + + templates + ? store.dispatch(setTemplates([{ ...newTemplate }, ...templates])) + : store.dispatch(setTemplates([{ ...newTemplate }])) + } + + handleRealtimeTemplateUpdate() { + const updatedTemplate = getFormattedTemplate(this.payload.new) + const currentState = store.getState() + const { templates, activeTemplate } = selectCreateTemplate(currentState) + const oldTemplate = templates && templates.find((template) => template.id == updatedTemplate.id) + + //handle template deleted case. + if (updatedTemplate.deletedAt) { + const newTemplateArr = templates && templates.filter((el) => el.id !== updatedTemplate.id) + store.dispatch(setTemplates(newTemplateArr)) + this.redirectToTemplateBoard(updatedTemplate) + } else { + // Address Postgres' 8kb pagesize limitation (See TOAST https://www.postgresql.org/docs/current/storage-toast.html) + // If `body` field (which can be larger than pagesize) is not changed, Supabase Realtime won't send large fields like this in `payload.new` + + // So, we need to check if the oldTask has valid body but new body field is not being sent in updatedTask, and add it if required + if (oldTemplate?.body && updatedTemplate.body === undefined) { + updatedTemplate.body = oldTemplate?.body + } + if (oldTemplate && oldTemplate.body && updatedTemplate.body) { + const oldImgSrcs = extractImgSrcs(oldTemplate.body) + const newImgSrcs = extractImgSrcs(updatedTemplate.body) + // Need to extract new image Srcs and replace it with old ones, because since we are creating a new url of images on each task details navigation, + // a second user navigating the task details will generate a new src and replace it in the database which causes the previous user to load the src again(because its new) + if (oldImgSrcs.length > 0 && newImgSrcs.length > 0) { + updatedTemplate.body = replaceImgSrcs(updatedTemplate.body, newImgSrcs, oldImgSrcs) + } + } + if (activeTemplate?.id == updatedTemplate.id) { + store.dispatch( + setActiveTemplate({ + ...updatedTemplate, + subTaskTemplates: activeTemplate.subTaskTemplates, + }), + ) + } + if (updatedTemplate?.parentId) { + this.applySubtemplateToParentTemplate(updatedTemplate) + return + } + const newTemplateArr = [ + updatedTemplate, + ...(templates?.filter((template) => template.id !== updatedTemplate.id) || []), + ] + store.dispatch(setTemplates(newTemplateArr)) + } + } + + handleRealtimeSubTemplates() { + if (this.payload.eventType === 'INSERT') { + const newTemplate = getFormattedTemplate(this.payload.new) + this.applySubtemplateToParentTemplate(newTemplate) + } else { + this.handleRealtimeTemplateUpdate() + } + } + + private applySubtemplateToParentTemplate(newTemplate: RealTimeTemplateResponse) { + if (!newTemplate?.parentId) return + const currentState = store.getState() + const { templates, activeTemplate } = selectCreateTemplate(currentState) + + if (activeTemplate?.id === newTemplate.parentId) { + store.dispatch( + setActiveTemplate({ + ...activeTemplate, + subTaskTemplates: [...(activeTemplate.subTaskTemplates || []), newTemplate], + }), + ) + } + + store.dispatch( + setTemplates( + templates.map((template) => + template.id === newTemplate.parentId + ? { + ...template, + subTaskTemplates: [...(template.subTaskTemplates || []), newTemplate], + } + : template, + ), + ), + ) //also append the subTaskTemplates to parent template on the templates store. + } +} From 827d762645b7ba4c447c4bfef934183c7a8e38c0 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 6 Jan 2026 16:10:29 +0545 Subject: [PATCH 15/81] fix(OUT-2847): seggregate task service for public and web - created a separate service file name public.service for tasks - created a separate abstract service which inherits base service and extended by PublicTasksService and TasksService. This file contains all the utilities shared by both of the services. - changed methods in public.controller for tasks - refactored tasks service --- src/app/api/tasks/public/public.controller.ts | 20 +- src/app/api/tasks/public/public.service.ts | 505 +++++++++++++++++ src/app/api/tasks/tasks.service.ts | 536 +----------------- src/app/api/tasks/tasks.shared.service.ts | 470 +++++++++++++++ 4 files changed, 1002 insertions(+), 529 deletions(-) create mode 100644 src/app/api/tasks/public/public.service.ts create mode 100644 src/app/api/tasks/tasks.shared.service.ts diff --git a/src/app/api/tasks/public/public.controller.ts b/src/app/api/tasks/public/public.controller.ts index e929d4c23..0638e7fde 100644 --- a/src/app/api/tasks/public/public.controller.ts +++ b/src/app/api/tasks/public/public.controller.ts @@ -9,6 +9,7 @@ import { TasksService } from '@api/tasks/tasks.service' import { decode, encode } from 'js-base64' import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { PublicTasksService } from '@api/tasks/public/public.service' export const getAllTasksPublic = async (req: NextRequest) => { const user = await authenticate(req) @@ -31,9 +32,8 @@ export const getAllTasksPublic = async (req: NextRequest) => { workflowState: workflowStateType && { type: workflowStateType }, } - const tasksService = new TasksService(user) + const tasksService = new PublicTasksService(user) const tasks = await tasksService.getAllTasks({ - fromPublicApi: true, showArchived: true, showUnarchived: true, all: !parentTaskId, @@ -55,8 +55,8 @@ export const getAllTasksPublic = async (req: NextRequest) => { export const getOneTaskPublic = async (req: NextRequest, { params }: IdParams) => { const { id } = await params const user = await authenticate(req) - const tasksService = new TasksService(user) - const task = await tasksService.getOneTask(id, true) //from public API is true + const tasksService = new PublicTasksService(user) + const task = await tasksService.getOneTask(id) return NextResponse.json(PublicTaskSerializer.serialize(task)) } @@ -69,8 +69,8 @@ export const createTaskPublic = async (req: NextRequest) => { const createPayload = await PublicTaskSerializer.deserializeCreatePayload(data, user.workspaceId) console.info('Deserialized create payload:', createPayload) - const tasksService = new TasksService(user) - const newTask = await tasksService.createTask(createPayload, { isPublicApi: true }) + const tasksService = new PublicTasksService(user) + const newTask = await tasksService.createTask(createPayload) console.info('Created new public task:', newTask) return NextResponse.json(PublicTaskSerializer.serialize(newTask)) @@ -81,9 +81,9 @@ export const updateTaskPublic = async (req: NextRequest, { params }: IdParams) = const user = await authenticate(req) const data = PublicTaskUpdateDtoSchema.parse(await req.json()) - const tasksService = new TasksService(user) + const tasksService = new PublicTasksService(user) const updatePayload = await PublicTaskSerializer.deserializeUpdatePayload(data, user.workspaceId) - const updatedTask = await tasksService.updateOneTask(id, updatePayload, { isPublicApi: true }) + const updatedTask = await tasksService.updateTask(id, updatePayload) return NextResponse.json(PublicTaskSerializer.serialize(updatedTask)) } @@ -92,7 +92,7 @@ export const deleteOneTaskPublic = async (req: NextRequest, { params }: IdParams const { id } = await params const recursive = req.nextUrl.searchParams.get('recursive') const user = await authenticate(req) - const tasksService = new TasksService(user) - const task = await tasksService.deleteOneTask(id, z.coerce.boolean().parse(recursive)) + const tasksService = new PublicTasksService(user) + const task = await tasksService.deleteTask(id, z.coerce.boolean().parse(recursive)) return NextResponse.json({ ...PublicTaskSerializer.serialize(task) }) } diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts new file mode 100644 index 000000000..23563bdfa --- /dev/null +++ b/src/app/api/tasks/public/public.service.ts @@ -0,0 +1,505 @@ +import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' +import { deleteTaskNotifications, sendTaskCreateNotifications, sendTaskUpdateNotifications } from '@/jobs/notifications' +import { TaskWithWorkflowState } from '@/types/db' +import { CreateTaskRequest, CreateTaskRequestSchema, UpdateTaskRequest, Viewers, ViewersSchema } from '@/types/dto/tasks.dto' +import { DISPATCHABLE_EVENT } from '@/types/webhook' +import { UserIdsType } from '@/utils/assignee' +import { isPastDateString } from '@/utils/dateHelper' +import { PoliciesService } from '@api/core/services/policies.service' +import { Resource } from '@api/core/types/api' +import { UserAction } from '@api/core/types/user' +import { + dispatchUpdatedWebhookEvent, + getArchivedStatus, + getTaskTimestamps, + queueBodyUpdatedWebhook, +} from '@api/tasks/tasks.helpers' +import { TasksSharedService } from '@api/tasks/tasks.shared.service' +import { AssigneeType, Prisma, PrismaClient, Source, StateType, Task, TaskTemplate, WorkflowState } from '@prisma/client' +import httpStatus from 'http-status' +import z from 'zod' +import APIError from '@api/core/exceptions/api' +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' + +export class PublicTasksService extends TasksSharedService { + async getAllTasks(queryFilters: { + all?: boolean + showIncompleteOnly?: boolean + showArchived: boolean + showUnarchived: boolean + internalUserId?: string + clientId?: string + companyId?: string + createdById?: string + parentId?: string | null + workflowState?: { type: StateType | { not: StateType } } + limit?: number + lastIdCursor?: string + }): Promise { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Read, Resource.Tasks) + + const filters: Prisma.TaskWhereInput = this.buildTaskPermissions() + + let isArchived: boolean | undefined = false + + if (queryFilters.all) { + isArchived = undefined + } else { + // Archived tasks are only accessible to IU + // If both archived filters are explicitly 0 / falsey for IU, shortcircuit and return empty array + if (!queryFilters.showArchived && !queryFilters.showUnarchived) { + return [] + } + isArchived = getArchivedStatus(queryFilters.showArchived, queryFilters.showUnarchived) + } + + // If `parentId` is present, filter by parentId, ELSE return top-level parent comments + filters.parentId = queryFilters.all + ? undefined // if querying all accessible tasks, parentId filter doesn't make sense + : await this.getParentIdFilter(queryFilters.parentId) + + const disjointTasksFilter: Prisma.TaskWhereInput = + queryFilters.all || queryFilters.parentId + ? {} // No need to support disjoint tasks when querying all tasks / subtasks + : await this.getDisjointTasksFilter() + + const where: Prisma.TaskWhereInput = { + ...filters, + ...disjointTasksFilter, + internalUserId: queryFilters.internalUserId, + clientId: queryFilters.clientId, + companyId: queryFilters.companyId, + createdById: queryFilters.createdById, + workflowState: queryFilters.workflowState || filters.workflowState, + isArchived, + } + + 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 tasks = await this.db.task.findMany({ + where, + orderBy, + ...pagination, + relationLoadStrategy: 'join', + include: { workflowState: true }, + }) + + return tasks + } + + async getOneTask(id: string): Promise { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Read, Resource.Tasks) + + // Build query filters based on role of user. IU can access all tasks related to a workspace + // 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 } }) + 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) + } + return task + } + + async createTask(data: CreateTaskRequest, opts?: { disableSubtaskTemplates?: boolean; manualTimestamp?: Date }) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Create, Resource.Tasks) + console.info('PublicTasksService#createTask | Creating task from public api with data:', data) + + const { internalUserId, clientId, companyId } = data + + const validatedIds = await this.validateUserIds(internalUserId, clientId, companyId) + console.info('PublicTasksService#createTask | Validated user IDs:', validatedIds) + + const { assigneeId, assigneeType } = this.getAssigneeFromUserIds({ + internalUserId: validatedIds.internalUserId, + clientId: validatedIds.clientId, + companyId: validatedIds.companyId, + }) + + //generate the label + const labelMappingService = new LabelMappingService(this.user) + const label = z.string().parse(await labelMappingService.getLabel(validatedIds)) + console.info('PublicTasksService#createTask | Generated label for task:', label) + if (data.parentId) { + const canCreateSubTask = await this.canCreateSubTask(data.parentId) + if (!canCreateSubTask) { + throw new APIError(httpStatus.BAD_REQUEST, 'Reached the maximum subtask depth for this task') + } + } + console.info('PublicTasksService#createTask | Subtask depth validated for parentId:', data.parentId) + + if (data.dueDate && isPastDateString(data.dueDate)) { + throw new APIError(httpStatus.BAD_REQUEST, 'Due date cannot be in the past') + } + + const { completedBy, completedByUserType, workflowStateStatus } = await this.getCompletionInfo(data?.workflowStateId) + console.info('PublicTasksService#createTask | Completion info determined:', { completedBy, completedByUserType }) + + // NOTE: This block strictly doesn't allow clients to create tasks + let createdById = z.string().parse(this.user.internalUserId) + + if (data.createdById) { + const internalUsers = await this.copilot.getInternalUsers({ limit: MAX_FETCH_ASSIGNEE_COUNT }) // there are 2 of these api running on this same serive. need to think about this later. + const createdBy = internalUsers?.data?.find((iu) => iu.id === data.createdById) + if (!createdBy) { + throw new APIError(httpStatus.BAD_REQUEST, 'The requested user for createdBy was not found') + } + createdById = createdBy.id + console.info('TasksService#createTask | createdById overridden for public API:', createdById) + } + + let viewers: Viewers = [] + if (data.viewers?.length) { + if (!validatedIds.internalUserId) { + throw new APIError(httpStatus.BAD_REQUEST, `Task cannot be created with viewers if its not assigned to an IU.`) + } + viewers = await this.validateViewers(data.viewers) + console.info('PublicTasksService#createTask | Viewers validated for task:', viewers) + } + + // Create a new task associated with current workspaceId. Also inject current request user as the creator. + const newTask = await this.db.task.create({ + data: { + ...data, + workspaceId: this.user.workspaceId, + createdById, + label: label, + completedBy, + completedByUserType, + source: Source.api, + assigneeId, + assigneeType, + viewers: viewers, + ...validatedIds, + ...(opts?.manualTimestamp && { createdAt: opts.manualTimestamp }), + ...(await getTaskTimestamps('create', this.user, data, undefined, workflowStateStatus)), + }, + include: { workflowState: true }, + }) + console.info('PublicTasksService#createTask | Task created with ID:', newTask.id) + + if (newTask) { + // Add activity logs + const activityLogger = new TasksActivityLogger(this.user, newTask) + await activityLogger.logNewTask({ + userId: createdById, + role: AssigneeType.internalUser, + }) //hardcoding internalUser as role since task can only be created by IUs. + console.info('TasksService#createTask | Activity log created for new task ID:', newTask.id) + + try { + if (newTask.body) { + const newBody = await this.updateTaskIdOfAttachmentsAfterCreation(newTask.body, newTask.id) + // Update task body with replaced attachment sources + await this.db.task.update({ + where: { id: newTask.id }, + data: { + body: newBody, + }, + }) + console.info('TasksService#createTask | Task body attachments updated for task ID:', newTask.id) + } + + // Add ltree path for task + await this.addPathToTask(newTask) + + // Increment parent task's subtask count, if exists + if (newTask.parentId) { + const subtaskService = new SubtaskService(this.user) + await Promise.all([ + subtaskService.addSubtaskCount(newTask.parentId), + this.setNewLastSubtaskUpdated(newTask.parentId), + ]) + } + console.info('PublicTasksService#createTask | Post-processing completed for task ID:', newTask.id) + } catch (e: unknown) { + // Manually rollback task creation + await this.db.$transaction([ + this.db.task.delete({ where: { id: newTask.id } }), + this.db.activityLog.deleteMany({ where: { taskId: newTask.id } }), + ]) + console.error('TasksService#createTask | Rolling back task creation', e) + throw new APIError(httpStatus.INTERNAL_SERVER_ERROR, 'Failed to post-process task, new task was not created.') + } + } + + // Send task created notifications to users + dispatch webhook + await Promise.all([ + sendTaskCreateNotifications.trigger({ user: this.user, task: newTask }), + this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskCreated, { + payload: PublicTaskSerializer.serialize(newTask), + workspaceId: this.user.workspaceId, + }), + ]) + + if (data.templateId) { + const templateService = new TemplatesService(this.user) + const template = await templateService.getOneTemplate(data.templateId) + + if (!template) { + throw new APIError(httpStatus.NOT_FOUND, 'The requested template was not found') + } + + if (template.subTaskTemplates.length) { + await Promise.all( + template.subTaskTemplates.map(async (sub, index) => { + const updatedSubTemplate = await templateService.getAppliedTemplateDescription(sub.id) + const manualTimeStamp = new Date(template.createdAt.getTime() + (template.subTaskTemplates.length - index) * 10) //maintain the order of subtasks in tasks with respect to subtasks in templates + await this.createSubtasksFromTemplate(updatedSubTemplate, newTask, manualTimeStamp) + }), + ) + } + } + + return newTask + } + + async updateTask(id: string, data: UpdateTaskRequest) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Update, Resource.Tasks) + const filters = this.buildTaskPermissions(id) + + const prevTask = await this.db.task.findFirst({ + where: filters, + relationLoadStrategy: 'join', + include: { workflowState: true }, + }) + + if (!prevTask) throw new APIError(httpStatus.NOT_FOUND, 'The requested task was not found') + + const { completedBy, completedByUserType } = await this.getCompletionInfo(data?.workflowStateId) + + const { internalUserId, clientId, companyId, ...dataWithoutUserIds } = data + + //todo : keep this in a separate shared util + const shouldUpdateUserIds = + (internalUserId !== undefined && internalUserId !== prevTask?.internalUserId) || + (clientId !== undefined && clientId !== prevTask?.clientId) || + (companyId !== undefined && companyId !== prevTask?.companyId) + + let validatedIds: UserIdsType | undefined + + if (shouldUpdateUserIds) { + validatedIds = await this.validateUserIds(internalUserId, clientId, companyId) + } + + const { assigneeId, assigneeType } = this.getAssigneeFromUserIds({ + internalUserId: validatedIds?.internalUserId ?? null, + clientId: validatedIds?.clientId ?? null, + companyId: validatedIds?.companyId ?? null, + }) + + let viewers: Viewers = ViewersSchema.parse(prevTask.viewers) + + const viewersResetCondition = shouldUpdateUserIds ? !!clientId || !!companyId : !prevTask.internalUserId + if (data.viewers) { + // only update of viewers attribute is available. No viewers in payload attribute means the data remains as it is in DB. + if (viewersResetCondition || !data.viewers?.length) { + viewers = [] // reset viewers to [] if task is not reassigned to IU. + } else if (data.viewers?.length) { + viewers = await this.validateViewers(data.viewers) + } + } + + const userAssignmentFields = shouldUpdateUserIds + ? { + ...validatedIds, + assigneeId, + assigneeType, + } + : {} + + // Validate updated due date to not be in the past + if (data.dueDate && isPastDateString(data.dueDate)) { + throw new APIError(httpStatus.BAD_REQUEST, 'Due date cannot be in the past') + } + const subtaskService = new SubtaskService(this.user) + + let updatedTask = await this.db.$transaction(async (tx) => { + //generate new label if prevTask has no assignee but now assigned to someone + let label: string = prevTask.label + if (!prevTask.assigneeId && assigneeId && assigneeType) { + const labelMappingService = new LabelMappingService(this.user) + labelMappingService.setTransaction(tx as PrismaClient) + //delete the existing label + await labelMappingService.deleteLabel(prevTask.label) + if (validatedIds) { + label = z.string().parse(await labelMappingService.getLabel(validatedIds)) + } + } + + // Set / reset lastArchivedDate if isArchived has been triggered, else remove it from the update query + let lastArchivedDate: Date | undefined | null = undefined + let archivedBy: string | null | undefined = undefined + if (data.isArchived !== undefined && prevTask.isArchived !== data.isArchived) { + lastArchivedDate = data.isArchived === true ? new Date() : data.isArchived === false ? null : undefined + archivedBy = data.isArchived === true ? this.user.internalUserId : data.isArchived === false ? null : undefined + } + + // Get the updated task + const updatedTask = await tx.task.update({ + where: { id }, + data: { + ...dataWithoutUserIds, + label, + lastArchivedDate, + archivedBy, + completedBy, + completedByUserType, + viewers, + ...userAssignmentFields, + ...(await getTaskTimestamps('update', this.user, data, prevTask)), + }, + include: { workflowState: true }, + }) + subtaskService.setTransaction(tx as PrismaClient) + // Archive / unarchive all subtasks if parent task is archived / unarchived + if (prevTask.isArchived !== data.isArchived && data.isArchived !== undefined) { + await subtaskService.toggleArchiveForAllSubtasks(id, data.isArchived) + } + + return updatedTask + }) + + if (updatedTask) { + const activityLogger = new TasksActivityLogger(this.user, updatedTask) + const isBodyChanged = prevTask.body !== updatedTask.body + await Promise.all([ + activityLogger.logTaskUpdated(prevTask), + this.setNewLastSubtaskUpdated(updatedTask.parentId), + sendTaskUpdateNotifications.trigger({ prevTask, updatedTask, user: this.user }), + dispatchUpdatedWebhookEvent(this.user, prevTask, updatedTask, true), + isBodyChanged ? queueBodyUpdatedWebhook(this.user, updatedTask) : undefined, + ]) + } + + return updatedTask + } + + async deleteTask(id: string, recursive: boolean = true) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Delete, Resource.Tasks) + const deletedBy = this.user.internalUserId + + // Try to delete existing client notification related to this task if exists + const task = await this.db.task.findFirst({ + where: { id, workspaceId: this.user.workspaceId }, + relationLoadStrategy: 'join', + include: { workflowState: true }, + }) + + if (!task) throw new APIError(httpStatus.NOT_FOUND, 'The requested task to delete was not found') + + if (!recursive) { + if (task.subtaskCount > 0) { + throw new APIError(httpStatus.CONFLICT, 'Cannot delete task with subtasks. Use recursive delete instead.') + } + } + + //delete the associated label + const labelMappingService = new LabelMappingService(this.user) + + const updatedTask = await this.db.$transaction(async (tx) => { + labelMappingService.setTransaction(tx as PrismaClient) + await labelMappingService.deleteLabel(task?.label) + + const deletedTask = await tx.task.update({ + where: { id, workspaceId: this.user.workspaceId }, + relationLoadStrategy: 'join', + include: { workflowState: true }, + data: { deletedAt: new Date(), deletedBy: deletedBy }, + }) + await this.setNewLastSubtaskUpdated(task.parentId) //updates lastSubtaskUpdated timestamp of parent task if there is task.parentId + const subtaskService = new SubtaskService(this.user) + subtaskService.setTransaction(tx as PrismaClient) + if (task.parentId) { + await subtaskService.decreaseSubtaskCount(task.parentId) + } + await subtaskService.softDeleteAllSubtasks(task.id) + return deletedTask + }) + + await Promise.all([ + deleteTaskNotifications.trigger({ user: this.user, task }), + this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskDeleted, { + payload: PublicTaskSerializer.serialize(updatedTask), + workspaceId: this.user.workspaceId, + }), + ]) + + return updatedTask + + // Logic to remove internal user notifications when a task is deleted / assignee is deleted + // ...In case requirements change later again + // const notificationService = new NotificationService(this.user) + // await notificationService.deleteInternalUserNotificationForTask(id) + } + + async hasMoreTasksAfterCursor( + id: string, + publicFilters: Partial[0]>, + ): Promise { + const nextTask = await this.db.task.findFirst({ + where: { ...publicFilters, workspaceId: this.user.workspaceId }, + cursor: { id }, + skip: 1, + orderBy: { createdAt: 'desc' }, + }) + return !!nextTask + } + + //todo: this needs to be in the shared service. + private async createSubtasksFromTemplate(data: TaskTemplate, parentTask: Task, manualTimestamp: Date) { + const { workspaceId, title, body, workflowStateId } = data + const previewMode = Boolean(this.user.clientId || this.user.companyId) + const { id: parentId, internalUserId, clientId, companyId, viewers } = parentTask + + try { + const createTaskPayload = CreateTaskRequestSchema.parse({ + title, + body, + workspaceId, + workflowStateId, + parentId, + templateId: undefined, //just to be safe from circular recursion + ...(previewMode && { + internalUserId, + clientId, + companyId, + viewers, + }), //On CRM view, we set assignee and viewers for subtasks same as the parent task. + }) + + await this.createTask(createTaskPayload, { disableSubtaskTemplates: true, manualTimestamp: manualTimestamp }) + } catch (e) { + const deleteTask = this.db.task.delete({ where: { id: parentId } }) + const deleteActivityLogs = this.db.activityLog.deleteMany({ where: { taskId: parentId } }) + + await this.db.$transaction(async (tx) => { + this.setTransaction(tx as PrismaClient) + await deleteTask + await deleteActivityLogs + this.unsetTransaction() + }) + + console.error('TasksService#createTask | Rolling back task creation', e) + throw new APIError( + httpStatus.INTERNAL_SERVER_ERROR, + 'Failed to create subtask from template, new task was not created.', + ) + } + } +} diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index ce2965bec..089f20f84 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -1,8 +1,6 @@ -import { maxSubTaskDepth } from '@/constants/tasks' -import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' import { deleteTaskNotifications, sendTaskCreateNotifications, sendTaskUpdateNotifications } from '@/jobs/notifications' import { sendClientUpdateTaskNotifications } from '@/jobs/notifications/send-client-task-update-notifications' -import { ClientResponse, CompanyResponse, InternalUsers, Uuid } from '@/types/common' +import { ClientResponse, CompanyResponse, InternalUsers } from '@/types/common' import { TaskWithWorkflowState } from '@/types/db' import { AncestorTaskResponse, @@ -15,59 +13,30 @@ import { import { DISPATCHABLE_EVENT } from '@/types/webhook' import { UserIdsType } from '@/utils/assignee' import { isPastDateString } from '@/utils/dateHelper' -import { buildLtree, buildLtreeNodeString, getIdsFromLtreePath } from '@/utils/ltree' -import { getFilePathFromUrl, replaceImageSrc } from '@/utils/signedUrlReplacer' +import { getIdsFromLtreePath } from '@/utils/ltree' +import { replaceImageSrc } 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 { PoliciesService } from '@api/core/services/policies.service' import { Resource } from '@api/core/types/api' import { UserAction, UserRole } from '@api/core/types/user' import { LabelMappingService } from '@api/label-mapping/label-mapping.service' import { PublicTaskSerializer } from '@api/tasks/public/public.serializer' import { SubtaskService } from '@api/tasks/subtasks.service' -import { - dispatchUpdatedWebhookEvent, - getArchivedStatus, - getTaskTimestamps, - queueBodyUpdatedWebhook, -} from '@api/tasks/tasks.helpers' +import { dispatchUpdatedWebhookEvent, getArchivedStatus, getTaskTimestamps } from '@api/tasks/tasks.helpers' import { TasksActivityLogger } from '@api/tasks/tasks.logger' +import { TasksSharedService } from '@api/tasks/tasks.shared.service' import { AssigneeType, Prisma, PrismaClient, Source, StateType, Task, TaskTemplate, WorkflowState } from '@prisma/client' import httpStatus from 'http-status' import { z } from 'zod' -import { TemplatesService } from './templates/templates.service' - -export class TasksService extends BaseService { - /** - * Builds filter for "get" service methods. - * If user is an IU, return filter for all tasks associated with this workspace - * If user is a client, return filter for just the tasks assigned to this clientId. - * If user is a client and has a companyId, return filter for just the tasks assigned to this clientId `OR` to this companyId - */ - private buildTaskPermissions(id?: string, includeViewer: boolean = true) { - const user = this.user - - // Default filters - let filters: Prisma.TaskWhereInput = { - id, - workspaceId: user.workspaceId, - } - - if (user.clientId || user.companyId) { - filters = { ...filters, ...this.getClientOrCompanyAssigneeFilter(includeViewer) } - } - - return filters - } +import { TemplatesService } from '@api/tasks/templates/templates.service' +export class TasksService extends TasksSharedService { async getAllTasks(queryFilters: { // Global filters all?: boolean showIncompleteOnly?: boolean - // Public Api filters - fromPublicApi?: boolean + // Column filters showArchived: boolean showUnarchived: boolean @@ -77,8 +46,6 @@ export class TasksService extends BaseService { createdById?: string parentId?: string | null workflowState?: { type: StateType | { not: StateType } } - limit?: number - lastIdCursor?: string // When this id field cursor is provided, we return data AFTER this id }): Promise { // Check if given user role is authorized access to this resource const policyGate = new PoliciesService(this.user) @@ -100,7 +67,7 @@ export class TasksService extends BaseService { isArchived = getArchivedStatus(queryFilters.showArchived, queryFilters.showUnarchived) } - if (queryFilters.showIncompleteOnly && !queryFilters.fromPublicApi) { + if (queryFilters.showIncompleteOnly) { queryFilters.workflowState = { type: { not: StateType.completed }, } @@ -132,26 +99,16 @@ export class TasksService extends BaseService { } const orderBy: Prisma.TaskOrderByWithRelationInput[] = [{ createdAt: 'desc' }] - if (!queryFilters.fromPublicApi) { - // For web, we show dueDate as the primary sort key - orderBy.unshift({ dueDate: { sort: 'asc', nulls: 'last' } }) - } - - const pagination: Prisma.TaskFindManyArgs = { - take: queryFilters.limit, - cursor: queryFilters.lastIdCursor ? { id: queryFilters.lastIdCursor } : undefined, - skip: queryFilters.lastIdCursor ? 1 : undefined, - } + orderBy.unshift({ dueDate: { sort: 'asc', nulls: 'last' } }) const tasks = await this.db.task.findMany({ where, orderBy, - ...pagination, relationLoadStrategy: 'join', include: { workflowState: true }, }) - if (!this.user.internalUserId || queryFilters.fromPublicApi) { + if (!this.user.internalUserId) { return tasks } @@ -163,10 +120,7 @@ export class TasksService extends BaseService { return filteredTasks } - async createTask( - data: CreateTaskRequest, - opts?: { isPublicApi?: boolean; disableSubtaskTemplates?: boolean; manualTimestamp?: Date }, - ) { + async createTask(data: CreateTaskRequest, opts?: { disableSubtaskTemplates?: boolean; manualTimestamp?: Date }) { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Create, Resource.Tasks) console.info('TasksService#createTask | Creating task with data:', data) @@ -205,16 +159,6 @@ export class TasksService extends BaseService { // NOTE: This block strictly doesn't allow clients to create tasks let createdById = z.string().parse(this.user.internalUserId) - if (data.createdById && opts?.isPublicApi) { - const internalUsers = await this.copilot.getInternalUsers({ limit: MAX_FETCH_ASSIGNEE_COUNT }) // there are 2 of these api running on this same serive. need to think about this later. - const createdBy = internalUsers?.data?.find((iu) => iu.id === data.createdById) - if (!createdBy) { - throw new APIError(httpStatus.BAD_REQUEST, 'The requested user for createdBy was not found') - } - createdById = createdBy.id - console.info('TasksService#createTask | createdById overridden for public API:', createdById) - } - let viewers: Viewers = [] if (data.viewers?.length) { if (!validatedIds.internalUserId) { @@ -233,7 +177,7 @@ export class TasksService extends BaseService { label: label, completedBy, completedByUserType, - source: opts?.isPublicApi ? Source.api : Source.web, + source: Source.web, assigneeId, assigneeType, viewers: viewers, @@ -321,14 +265,13 @@ export class TasksService extends BaseService { return newTask } - async getOneTask(id: string, fromPublicApi?: boolean): Promise { + async getOneTask(id: string): Promise { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Read, Resource.Tasks) // Build query filters based on role of user. IU can access all tasks related to a workspace // while clients can only view the tasks assigned to them or their company - const filters = this.buildTaskPermissions(id) - const where = fromPublicApi ? { ...filters, deletedAt: { not: undefined } } : filters + const where = this.buildTaskPermissions(id) const task = await this.db.task.findFirst({ where }) if (!task) throw new APIError(httpStatus.NOT_FOUND, 'The requested task was not found') @@ -364,7 +307,7 @@ export class TasksService extends BaseService { } } - async updateOneTask(id: string, data: UpdateTaskRequest, opts?: { isPublicApi: boolean }) { + async updateOneTask(id: string, data: UpdateTaskRequest) { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Update, Resource.Tasks) @@ -477,8 +420,7 @@ export class TasksService extends BaseService { activityLogger.logTaskUpdated(prevTask), this.setNewLastSubtaskUpdated(updatedTask.parentId), sendTaskUpdateNotifications.trigger({ prevTask, updatedTask, user: this.user }), - dispatchUpdatedWebhookEvent(this.user, prevTask, updatedTask, opts?.isPublicApi || false), - isBodyChanged && opts?.isPublicApi ? queueBodyUpdatedWebhook(this.user, updatedTask) : undefined, + dispatchUpdatedWebhookEvent(this.user, prevTask, updatedTask, false), ]) } @@ -544,232 +486,6 @@ export class TasksService extends BaseService { // await notificationService.deleteInternalUserNotificationForTask(id) } - async getPathOfTask(id: string) { - return ( - await this.db.$queryRaw<{ path: string }[] | null>` - SELECT "path" - FROM "Tasks" - WHERE id::text = ${id} - AND "workspaceId" = ${this.user.workspaceId} - ` - )?.[0]?.path - } - - private 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 } : {} - } - - private getDisjointTasksFilter = () => { - // For disjoint tasks, show this subtask as a root-level task - // This n-node matcher matches any task tree chain where previous task's assigneeId is not self's - // E.g. A -> B -> C, where A is assigned to user 1, B is assigned to user 2, C is assigned to user 2 - // For user 2, task B should show up as a parent task in the main task board - const disjointTasksFilter: Promise = (async () => { - if (this.user.role === UserRole.IU && !this.user.clientId && !this.user.companyId) { - const currentInternalUser = await this.copilot.getInternalUser(z.string().parse(this.user.internalUserId)) - if (!currentInternalUser.isClientAccessLimited) return {} - - const accesibleCompanyIds = currentInternalUser.companyAccessList || [] - return { - OR: [ - { - parent: { companyId: { notIn: accesibleCompanyIds } }, - }, - { - parentId: null, - }, - ], - } - } - - return { - OR: [ - // Parent is not assigned to client - { - ...this.getClientOrCompanyAssigneeFilter(), // Prevent overwriting of OR statement - parent: { - AND: [ - { - OR: [ - // Disjoint task if parent has no assignee - { clientId: null, companyId: null }, - { - NOT: { - // Do not disjoint task if parent task belongs to the same client / company - OR: [ - // Disjoint task if parent is a client task for a different client under the same company - { clientId: this.user.clientId, companyId: this.user.companyId }, - // Disjoint task if parent is not a company task for the same company that client belongs to - { clientId: null, companyId: this.user.companyId }, - ], - }, - }, - ], - }, - { - NOT: { - viewers: { - hasSome: [ - { clientId: this.user.clientId, companyId: this.user.companyId }, - { companyId: this.user.companyId }, - ], - }, - }, //AND do not disjoint if parent is accesible to the client through client visibility. - }, - ], - }, - }, - // Task is a parent / standalone task - { - ...this.getClientOrCompanyAssigneeFilter(), - parentId: null, - }, - ], - } - })() - - return disjointTasksFilter - } - - private async getParentIdFilter(parentId?: string | null) { - // If `parentId` is present, filter by parentId - if (parentId) { - return z.string().uuid().parse(parentId) - } - if (this.user.companyId) { - // If user is client, flatten subtasks by not filtering by parentId right now - return undefined - } - // If user is IU, no need to flatten subtasks - if (this.user.role === UserRole.IU && !this.user.clientId) { - if (this.user.internalUserId) { - const currentInternalUser = await this.copilot.getInternalUser(this.user.internalUserId) - if (currentInternalUser.isClientAccessLimited) { - return undefined - } - } - return null - } - return undefined - } - - private async addPathToTask(task: Task) { - let path: string = buildLtreeNodeString(task.id) - if (task.parentId) { - const parentPath = await this.getPathOfTask(task.parentId) - if (!parentPath) { - throw new APIError(httpStatus.NOT_FOUND, 'The requested parent task was not found') - } - path = buildLtree(parentPath, task.id) - } - - await this.db.$executeRaw` - UPDATE "Tasks" - SET path = ${buildLtreeNodeString(path)}::ltree - WHERE id::text = ${task.id} - AND "workspaceId" = ${this.user.workspaceId} - ` - } - - private async updateTaskIdOfAttachmentsAfterCreation(htmlString: string, task_id: 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 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}/${fileName}` - const supabaseActions = new SupabaseActions() - copyAttachmentPromises.push(supabaseActions.moveAttachment(filePath, newFilePath)) - newFilePaths.push({ originalSrc, newFilePath }) - } - - await Promise.all(copyAttachmentPromises) - - 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, - }, - }) - return htmlString - } - async setNewLastActivityLogUpdated(taskId: string) { // This shouldn't crash our app, just in case try { @@ -936,224 +652,6 @@ export class TasksService extends BaseService { return await subtaskService.getAccessiblePathTasks(parentTasks) } - private async checkClientAccessForTask(task: Task, internalUserId: string) { - const currentInternalUser = await this.copilot.getInternalUser(internalUserId) - if (!currentInternalUser.isClientAccessLimited) return - - const isLimitedTask = !(await this.filterTasksByClientAccess([task], currentInternalUser)).length - if (isLimitedTask) { - throw new APIError( - httpStatus.UNAUTHORIZED, - "This task's assignee is not included in your list of accessible clients / companies", - ) - } - } - - private async filterTasksByClientAccess(tasks: T[], currentInternalUser: InternalUsers): Promise { - const hasClientOrCompanyTasks = tasks.some((task) => task.companyId) - if (!hasClientOrCompanyTasks) { - return tasks - } - - return tasks.filter((task) => { - // Pass all tasks that are unassigned or are assigned to IU - if ((!task.internalUserId && !task.clientId && !task.companyId) || task.internalUserId) return true - // For remaining client or company tasks, check if companyAccessList includes this companyId - return currentInternalUser.companyAccessList?.includes(task.companyId || '') - }) - } - - async hasMoreTasksAfterCursor( - id: string, - publicFilters: Partial[0]>, - ): Promise { - const nextTask = await this.db.task.findFirst({ - where: { ...publicFilters, workspaceId: this.user.workspaceId }, - cursor: { id }, - skip: 1, - orderBy: { createdAt: 'desc' }, - }) - return !!nextTask - } - - async canCreateSubTask(taskId: string): Promise { - const parentPath = await this.getPathOfTask(taskId) - if (!parentPath) { - throw new APIError(httpStatus.NOT_FOUND, 'The requested parent task was not found') - } - const uuidLength = parentPath.split('.').length - if (!uuidLength) return true - return uuidLength <= maxSubTaskDepth - } - - private async getCompletionInfo(targetWorkflowStateId?: string | null): Promise<{ - completedBy: string | null - completedByUserType: AssigneeType | null - workflowStateStatus: StateType - }> { - if (!targetWorkflowStateId) { - return { completedBy: null, completedByUserType: null, workflowStateStatus: StateType.unstarted } - } - - const role = this.user.role - - const workflowState = await this.db.workflowState.findFirst({ - where: { id: targetWorkflowStateId, workspaceId: this.user.workspaceId }, - select: { type: true }, - }) - - if (!workflowState) { - throw new APIError(httpStatus.NOT_FOUND, 'The requested workflow state was not found') - } - - if (workflowState.type === StateType.completed) { - return { - completedBy: z.string().parse(role === AssigneeType.internalUser ? this.user.internalUserId : this.user.clientId), - completedByUserType: role, - workflowStateStatus: workflowState.type, - } - } - - return { completedBy: null, completedByUserType: null, workflowStateStatus: workflowState.type } - } - - private async validateUserIds( - internalUserId?: string | null, - clientId?: string | null, - companyId?: string | null, - ): Promise<{ - internalUserId: string | null - clientId: string | null - companyId: string | null - }> { - if (internalUserId) { - const internalUsers = (await this.copilot.getInternalUsers({ limit: MAX_FETCH_ASSIGNEE_COUNT })).data - const isValid = internalUsers?.some((user) => user.id === internalUserId) - - if (!isValid) { - throw new APIError(httpStatus.BAD_REQUEST, `Invalid internalUserId`) - } - - return { - internalUserId, - clientId: null, - companyId: null, - } - } - - if (clientId) { - const client = await this.copilot.getClient(clientId) - - const isValidCompany = companyId ? client?.companyIds?.includes(companyId) : false - - if (!client) { - throw new APIError(httpStatus.BAD_REQUEST, `Invalid clientId`) - } - - if (!companyId || !isValidCompany) { - throw new APIError(httpStatus.BAD_REQUEST, `Invalid company for the provided clientId`) - } - - return { - internalUserId: null, - clientId, - companyId, - } - } - - if (companyId) { - const companies = (await this.copilot.getCompanies({ limit: MAX_FETCH_ASSIGNEE_COUNT })).data - const isValid = companies?.some((company) => company.id === companyId) - - if (!isValid) { - throw new APIError(httpStatus.BAD_REQUEST, `Invalid companyId`) - } - - return { - internalUserId: null, - clientId: null, - companyId, - } - } - - return { - internalUserId: null, - clientId: null, - companyId: null, - } - } - - private getAssigneeFromUserIds(userIds: { - internalUserId: string | null - clientId: string | null - companyId: string | null - }): { assigneeId: string | null; assigneeType: AssigneeType | null } { - const { internalUserId, clientId, companyId } = userIds - - if (internalUserId) { - return { - assigneeId: internalUserId, - assigneeType: AssigneeType.internalUser, - } - } - - if (clientId) { - return { - assigneeId: clientId, - assigneeType: AssigneeType.client, - } - } - - if (companyId) { - return { - assigneeId: companyId, - assigneeType: AssigneeType.company, - } - } - return { - assigneeId: null, - assigneeType: null, - } - } - - private async setNewLastSubtaskUpdated(parentId?: z.infer | null) { - if (!parentId) { - return - } - try { - await this.db.task.update({ - where: { id: parentId, workspaceId: this.user.workspaceId }, - data: { - lastSubtaskUpdated: new Date(), - }, - }) - } catch (e) { - console.error('TaskService#setNewLastSubtaskUpdated::', e) - } - } - - private async validateViewers(viewers: Viewers) { - if (!viewers?.length) return [] - const viewer = viewers[0] - try { - if (viewer.clientId) { - const client = await this.copilot.getClient(viewer.clientId) //support looping viewers and filtering from getClients instead of doing getClient if we do support many viewers in the future. - if (!client.companyIds?.includes(viewers[0].companyId)) { - throw new APIError(httpStatus.BAD_REQUEST, 'Invalid companyId for the provided viewer.') - } - } else { - await this.copilot.getCompany(viewer.companyId) - } - } catch (err) { - if (err instanceof APIError) { - throw err - } - throw new APIError(httpStatus.BAD_REQUEST, `Viewer should be a CU.`) - } - - return viewers - } - private async createSubtasksFromTemplate(data: TaskTemplate, parentTask: Task, manualTimestamp: Date) { const { workspaceId, title, body, workflowStateId } = data const previewMode = Boolean(this.user.clientId || this.user.companyId) diff --git a/src/app/api/tasks/tasks.shared.service.ts b/src/app/api/tasks/tasks.shared.service.ts new file mode 100644 index 000000000..ea2522b13 --- /dev/null +++ b/src/app/api/tasks/tasks.shared.service.ts @@ -0,0 +1,470 @@ +import { maxSubTaskDepth } from '@/constants/tasks' +import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' +import { InternalUsers, Uuid } from '@/types/common' +import { Viewers } from '@/types/dto/tasks.dto' +import { buildLtree, buildLtreeNodeString } from '@/utils/ltree' +import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' +import { getSignedUrl } from '@/utils/signUrl' +import { SupabaseActions } from '@/utils/SupabaseActions' +import { BaseService } from '@api/core/services/base.service' +import { AssigneeType, Prisma, StateType, Task } 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' + +//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 { + /** + * Builds filter for "get" service methods. + * If user is an IU, return filter for all tasks associated with this workspace + * If user is a client, return filter for just the tasks assigned to this clientId. + * If user is a client and has a companyId, return filter for just the tasks assigned to this clientId `OR` to this companyId + */ + protected buildTaskPermissions(id?: string, includeViewer: boolean = true) { + const user = this.user + + // Default filters + let filters: Prisma.TaskWhereInput = { + id, + workspaceId: user.workspaceId, + } + + if (user.clientId || user.companyId) { + filters = { ...filters, ...this.getClientOrCompanyAssigneeFilter(includeViewer) } + } + + return filters + } + + 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 } : {} + } + + protected async getParentIdFilter(parentId?: string | null) { + // If `parentId` is present, filter by parentId + if (parentId) { + return z.string().uuid().parse(parentId) + } + if (this.user.companyId) { + // If user is client, flatten subtasks by not filtering by parentId right now + return undefined + } + // If user is IU, no need to flatten subtasks + if (this.user.role === UserRole.IU && !this.user.clientId) { + if (this.user.internalUserId) { + const currentInternalUser = await this.copilot.getInternalUser(this.user.internalUserId) + if (currentInternalUser.isClientAccessLimited) { + return undefined + } + } + return null + } + return undefined + } + + protected getDisjointTasksFilter = () => { + // For disjoint tasks, show this subtask as a root-level task + // This n-node matcher matches any task tree chain where previous task's assigneeId is not self's + // E.g. A -> B -> C, where A is assigned to user 1, B is assigned to user 2, C is assigned to user 2 + // For user 2, task B should show up as a parent task in the main task board + const disjointTasksFilter: Promise = (async () => { + if (this.user.role === UserRole.IU && !this.user.clientId && !this.user.companyId) { + const currentInternalUser = await this.copilot.getInternalUser(z.string().parse(this.user.internalUserId)) + if (!currentInternalUser.isClientAccessLimited) return {} + + const accesibleCompanyIds = currentInternalUser.companyAccessList || [] + return { + OR: [ + { + parent: { companyId: { notIn: accesibleCompanyIds } }, + }, + { + parentId: null, + }, + ], + } + } + + return { + OR: [ + // Parent is not assigned to client + { + ...this.getClientOrCompanyAssigneeFilter(), // Prevent overwriting of OR statement + parent: { + AND: [ + { + OR: [ + // Disjoint task if parent has no assignee + { clientId: null, companyId: null }, + { + NOT: { + // Do not disjoint task if parent task belongs to the same client / company + OR: [ + // Disjoint task if parent is a client task for a different client under the same company + { clientId: this.user.clientId, companyId: this.user.companyId }, + // Disjoint task if parent is not a company task for the same company that client belongs to + { clientId: null, companyId: this.user.companyId }, + ], + }, + }, + ], + }, + { + NOT: { + viewers: { + hasSome: [ + { clientId: this.user.clientId, companyId: this.user.companyId }, + { companyId: this.user.companyId }, + ], + }, + }, //AND do not disjoint if parent is accesible to the client through client visibility. + }, + ], + }, + }, + // Task is a parent / standalone task + { + ...this.getClientOrCompanyAssigneeFilter(), + parentId: null, + }, + ], + } + })() + + return disjointTasksFilter + } + + protected async checkClientAccessForTask(task: Task, internalUserId: string) { + const currentInternalUser = await this.copilot.getInternalUser(internalUserId) + if (!currentInternalUser.isClientAccessLimited) return + + const isLimitedTask = !(await this.filterTasksByClientAccess([task], currentInternalUser)).length + if (isLimitedTask) { + throw new APIError( + httpStatus.UNAUTHORIZED, + "This task's assignee is not included in your list of accessible clients / companies", + ) + } + } + + protected async filterTasksByClientAccess(tasks: T[], currentInternalUser: InternalUsers): Promise { + const hasClientOrCompanyTasks = tasks.some((task) => task.companyId) + if (!hasClientOrCompanyTasks) { + return tasks + } + + return tasks.filter((task) => { + // Pass all tasks that are unassigned or are assigned to IU + if ((!task.internalUserId && !task.clientId && !task.companyId) || task.internalUserId) return true + // For remaining client or company tasks, check if companyAccessList includes this companyId + return currentInternalUser.companyAccessList?.includes(task.companyId || '') + }) + } + + protected async validateUserIds( + internalUserId?: string | null, + clientId?: string | null, + companyId?: string | null, + ): Promise<{ + internalUserId: string | null + clientId: string | null + companyId: string | null + }> { + if (internalUserId) { + const internalUsers = (await this.copilot.getInternalUsers({ limit: MAX_FETCH_ASSIGNEE_COUNT })).data + const isValid = internalUsers?.some((user) => user.id === internalUserId) + + if (!isValid) { + throw new APIError(httpStatus.BAD_REQUEST, `Invalid internalUserId`) + } + + return { + internalUserId, + clientId: null, + companyId: null, + } + } + + if (clientId) { + const client = await this.copilot.getClient(clientId) + + const isValidCompany = companyId ? client?.companyIds?.includes(companyId) : false + + if (!client) { + throw new APIError(httpStatus.BAD_REQUEST, `Invalid clientId`) + } + + if (!companyId || !isValidCompany) { + throw new APIError(httpStatus.BAD_REQUEST, `Invalid company for the provided clientId`) + } + + return { + internalUserId: null, + clientId, + companyId, + } + } + + if (companyId) { + const companies = (await this.copilot.getCompanies({ limit: MAX_FETCH_ASSIGNEE_COUNT })).data + const isValid = companies?.some((company) => company.id === companyId) + + if (!isValid) { + throw new APIError(httpStatus.BAD_REQUEST, `Invalid companyId`) + } + + return { + internalUserId: null, + clientId: null, + companyId, + } + } + + return { + internalUserId: null, + clientId: null, + companyId: null, + } + } + + protected getAssigneeFromUserIds(userIds: { + internalUserId: string | null + clientId: string | null + companyId: string | null + }): { assigneeId: string | null; assigneeType: AssigneeType | null } { + const { internalUserId, clientId, companyId } = userIds + + if (internalUserId) { + return { + assigneeId: internalUserId, + assigneeType: AssigneeType.internalUser, + } + } + + if (clientId) { + return { + assigneeId: clientId, + assigneeType: AssigneeType.client, + } + } + + if (companyId) { + return { + assigneeId: companyId, + assigneeType: AssigneeType.company, + } + } + return { + assigneeId: null, + assigneeType: null, + } + } + + async canCreateSubTask(taskId: string): Promise { + const parentPath = await this.getPathOfTask(taskId) + if (!parentPath) { + throw new APIError(httpStatus.NOT_FOUND, 'The requested parent task was not found') + } + const uuidLength = parentPath.split('.').length + if (!uuidLength) return true + return uuidLength <= maxSubTaskDepth + } + + async getPathOfTask(id: string) { + return ( + await this.db.$queryRaw<{ path: string }[] | null>` + SELECT "path" + FROM "Tasks" + WHERE id::text = ${id} + AND "workspaceId" = ${this.user.workspaceId} + ` + )?.[0]?.path + } + + protected async getCompletionInfo(targetWorkflowStateId?: string | null): Promise<{ + completedBy: string | null + completedByUserType: AssigneeType | null + workflowStateStatus: StateType + }> { + if (!targetWorkflowStateId) { + return { completedBy: null, completedByUserType: null, workflowStateStatus: StateType.unstarted } + } + + const role = this.user.role + + const workflowState = await this.db.workflowState.findFirst({ + where: { id: targetWorkflowStateId, workspaceId: this.user.workspaceId }, + select: { type: true }, + }) + + if (!workflowState) { + throw new APIError(httpStatus.NOT_FOUND, 'The requested workflow state was not found') + } + + if (workflowState.type === StateType.completed) { + return { + completedBy: z.string().parse(role === AssigneeType.internalUser ? this.user.internalUserId : this.user.clientId), + completedByUserType: role, + workflowStateStatus: workflowState.type, + } + } + + return { completedBy: null, completedByUserType: null, workflowStateStatus: workflowState.type } + } + + protected async validateViewers(viewers: Viewers) { + if (!viewers?.length) return [] + const viewer = viewers[0] + try { + if (viewer.clientId) { + const client = await this.copilot.getClient(viewer.clientId) //support looping viewers and filtering from getClients instead of doing getClient if we do support many viewers in the future. + if (!client.companyIds?.includes(viewers[0].companyId)) { + throw new APIError(httpStatus.BAD_REQUEST, 'Invalid companyId for the provided viewer.') + } + } else { + await this.copilot.getCompany(viewer.companyId) + } + } catch (err) { + if (err instanceof APIError) { + throw err + } + throw new APIError(httpStatus.BAD_REQUEST, `Viewer should be a CU.`) + } + + return viewers + } + + protected async updateTaskIdOfAttachmentsAfterCreation(htmlString: string, task_id: 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 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}/${fileName}` + const supabaseActions = new SupabaseActions() + copyAttachmentPromises.push(supabaseActions.moveAttachment(filePath, newFilePath)) + newFilePaths.push({ originalSrc, newFilePath }) + } + + await Promise.all(copyAttachmentPromises) + + 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, + }, + }) + return htmlString + } + + protected async addPathToTask(task: Task) { + let path: string = buildLtreeNodeString(task.id) + if (task.parentId) { + const parentPath = await this.getPathOfTask(task.parentId) + if (!parentPath) { + throw new APIError(httpStatus.NOT_FOUND, 'The requested parent task was not found') + } + path = buildLtree(parentPath, task.id) + } + + await this.db.$executeRaw` + UPDATE "Tasks" + SET path = ${buildLtreeNodeString(path)}::ltree + WHERE id::text = ${task.id} + AND "workspaceId" = ${this.user.workspaceId} + ` + } + + protected async setNewLastSubtaskUpdated(parentId?: z.infer | null) { + if (!parentId) { + return + } + try { + await this.db.task.update({ + where: { id: parentId, workspaceId: this.user.workspaceId }, + data: { + lastSubtaskUpdated: new Date(), + }, + }) + } catch (e) { + console.error('TaskService#setNewLastSubtaskUpdated::', e) + } + } +} From 464f2ab1147a74eadb72ae4901c53d964ec633bc Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 7 Jan 2026 14:25:23 +0545 Subject: [PATCH 16/81] fix(OUT-2847): refactored createSubtasksFromTemplate to be resuable from both services using the template pattern --- src/app/api/tasks/public/public.service.ts | 42 ------------------ src/app/api/tasks/tasks.service.ts | 41 ------------------ src/app/api/tasks/tasks.shared.service.ts | 50 +++++++++++++++++++++- 3 files changed, 48 insertions(+), 85 deletions(-) diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index 23563bdfa..6b3c553af 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -460,46 +460,4 @@ export class PublicTasksService extends TasksSharedService { }) return !!nextTask } - - //todo: this needs to be in the shared service. - private async createSubtasksFromTemplate(data: TaskTemplate, parentTask: Task, manualTimestamp: Date) { - const { workspaceId, title, body, workflowStateId } = data - const previewMode = Boolean(this.user.clientId || this.user.companyId) - const { id: parentId, internalUserId, clientId, companyId, viewers } = parentTask - - try { - const createTaskPayload = CreateTaskRequestSchema.parse({ - title, - body, - workspaceId, - workflowStateId, - parentId, - templateId: undefined, //just to be safe from circular recursion - ...(previewMode && { - internalUserId, - clientId, - companyId, - viewers, - }), //On CRM view, we set assignee and viewers for subtasks same as the parent task. - }) - - await this.createTask(createTaskPayload, { disableSubtaskTemplates: true, manualTimestamp: manualTimestamp }) - } catch (e) { - const deleteTask = this.db.task.delete({ where: { id: parentId } }) - const deleteActivityLogs = this.db.activityLog.deleteMany({ where: { taskId: parentId } }) - - await this.db.$transaction(async (tx) => { - this.setTransaction(tx as PrismaClient) - await deleteTask - await deleteActivityLogs - this.unsetTransaction() - }) - - console.error('TasksService#createTask | Rolling back task creation', e) - throw new APIError( - httpStatus.INTERNAL_SERVER_ERROR, - 'Failed to create subtask from template, new task was not created.', - ) - } - } } diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index 089f20f84..fbff22fbb 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -651,45 +651,4 @@ export class TasksService extends TasksSharedService { const subtaskService = new SubtaskService(this.user) return await subtaskService.getAccessiblePathTasks(parentTasks) } - - private async createSubtasksFromTemplate(data: TaskTemplate, parentTask: Task, manualTimestamp: Date) { - const { workspaceId, title, body, workflowStateId } = data - const previewMode = Boolean(this.user.clientId || this.user.companyId) - const { id: parentId, internalUserId, clientId, companyId, viewers } = parentTask - - try { - const createTaskPayload = CreateTaskRequestSchema.parse({ - title, - body, - workspaceId, - workflowStateId, - parentId, - templateId: undefined, //just to be safe from circular recursion - ...(previewMode && { - internalUserId, - clientId, - companyId, - viewers, - }), //On CRM view, we set assignee and viewers for subtasks same as the parent task. - }) - - await this.createTask(createTaskPayload, { disableSubtaskTemplates: true, manualTimestamp: manualTimestamp }) - } catch (e) { - const deleteTask = this.db.task.delete({ where: { id: parentId } }) - const deleteActivityLogs = this.db.activityLog.deleteMany({ where: { taskId: parentId } }) - - await this.db.$transaction(async (tx) => { - this.setTransaction(tx as PrismaClient) - await deleteTask - await deleteActivityLogs - this.unsetTransaction() - }) - - console.error('TasksService#createTask | Rolling back task creation', e) - throw new APIError( - httpStatus.INTERNAL_SERVER_ERROR, - 'Failed to create subtask from template, new task was not created.', - ) - } - } } diff --git a/src/app/api/tasks/tasks.shared.service.ts b/src/app/api/tasks/tasks.shared.service.ts index ea2522b13..2cf52bc24 100644 --- a/src/app/api/tasks/tasks.shared.service.ts +++ b/src/app/api/tasks/tasks.shared.service.ts @@ -1,13 +1,13 @@ import { maxSubTaskDepth } from '@/constants/tasks' import { MAX_FETCH_ASSIGNEE_COUNT } from '@/constants/users' import { InternalUsers, Uuid } from '@/types/common' -import { Viewers } from '@/types/dto/tasks.dto' +import { CreateTaskRequest, CreateTaskRequestSchema, Viewers } from '@/types/dto/tasks.dto' import { buildLtree, buildLtreeNodeString } from '@/utils/ltree' import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' import { getSignedUrl } from '@/utils/signUrl' import { SupabaseActions } from '@/utils/SupabaseActions' import { BaseService } from '@api/core/services/base.service' -import { AssigneeType, Prisma, StateType, Task } from '@prisma/client' +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' @@ -15,6 +15,11 @@ import { UserRole } from '@api/core/types/user' //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 { + protected abstract createTask( + data: CreateTaskRequest, + opts?: { disableSubtaskTemplates?: boolean; manualTimestamp?: Date }, + ): Promise + /** * Builds filter for "get" service methods. * If user is an IU, return filter for all tasks associated with this workspace @@ -467,4 +472,45 @@ export abstract class TasksSharedService extends BaseService { console.error('TaskService#setNewLastSubtaskUpdated::', e) } } + + protected async createSubtasksFromTemplate(data: TaskTemplate, parentTask: Task, manualTimestamp: Date) { + const { workspaceId, title, body, workflowStateId } = data + const previewMode = Boolean(this.user.clientId || this.user.companyId) + const { id: parentId, internalUserId, clientId, companyId, viewers } = parentTask + + try { + const createTaskPayload = CreateTaskRequestSchema.parse({ + title, + body, + workspaceId, + workflowStateId, + parentId, + templateId: undefined, //just to be safe from circular recursion + ...(previewMode && { + internalUserId, + clientId, + companyId, + viewers, + }), //On CRM view, we set assignee and viewers for subtasks same as the parent task. + }) + + await this.createTask(createTaskPayload, { disableSubtaskTemplates: true, manualTimestamp: manualTimestamp }) + } catch (e) { + const deleteTask = this.db.task.delete({ where: { id: parentId } }) + const deleteActivityLogs = this.db.activityLog.deleteMany({ where: { taskId: parentId } }) + + await this.db.$transaction(async (tx) => { + this.setTransaction(tx as PrismaClient) + await deleteTask + await deleteActivityLogs + this.unsetTransaction() + }) + + console.error('TasksService#createTask | Rolling back task creation', e) + throw new APIError( + httpStatus.INTERNAL_SERVER_ERROR, + 'Failed to create subtask from template, new task was not created.', + ) + } + } } From b30416cc456972681750ef371d866096d82ae9bd Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 12 Jan 2026 12:47:52 +0545 Subject: [PATCH 17/81] fix(OUT-2847): file naming convention followed and changed access scope of utility methods --- src/app/api/tasks/public/public.service.ts | 2 +- src/app/api/tasks/tasks.service.ts | 2 +- .../tasks/{tasks.shared.service.ts => tasksShared.service.ts} | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/app/api/tasks/{tasks.shared.service.ts => tasksShared.service.ts} (99%) diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index 6b3c553af..f4f3745f7 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -14,7 +14,7 @@ import { getTaskTimestamps, queueBodyUpdatedWebhook, } from '@api/tasks/tasks.helpers' -import { TasksSharedService } from '@api/tasks/tasks.shared.service' +import { TasksSharedService } from '@/app/api/tasks/tasksShared.service' import { AssigneeType, Prisma, PrismaClient, Source, StateType, Task, TaskTemplate, WorkflowState } from '@prisma/client' import httpStatus from 'http-status' import z from 'zod' diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index fbff22fbb..4c1eab127 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -25,7 +25,7 @@ import { PublicTaskSerializer } from '@api/tasks/public/public.serializer' import { SubtaskService } from '@api/tasks/subtasks.service' import { dispatchUpdatedWebhookEvent, getArchivedStatus, getTaskTimestamps } from '@api/tasks/tasks.helpers' import { TasksActivityLogger } from '@api/tasks/tasks.logger' -import { TasksSharedService } from '@api/tasks/tasks.shared.service' +import { TasksSharedService } from '@/app/api/tasks/tasksShared.service' import { AssigneeType, Prisma, PrismaClient, Source, StateType, Task, TaskTemplate, WorkflowState } from '@prisma/client' import httpStatus from 'http-status' import { z } from 'zod' diff --git a/src/app/api/tasks/tasks.shared.service.ts b/src/app/api/tasks/tasksShared.service.ts similarity index 99% rename from src/app/api/tasks/tasks.shared.service.ts rename to src/app/api/tasks/tasksShared.service.ts index 2cf52bc24..d5ca9abf8 100644 --- a/src/app/api/tasks/tasks.shared.service.ts +++ b/src/app/api/tasks/tasksShared.service.ts @@ -302,7 +302,7 @@ export abstract class TasksSharedService extends BaseService { } } - async canCreateSubTask(taskId: string): Promise { + protected async canCreateSubTask(taskId: string): Promise { const parentPath = await this.getPathOfTask(taskId) if (!parentPath) { throw new APIError(httpStatus.NOT_FOUND, 'The requested parent task was not found') @@ -312,7 +312,7 @@ export abstract class TasksSharedService extends BaseService { return uuidLength <= maxSubTaskDepth } - async getPathOfTask(id: string) { + private async getPathOfTask(id: string) { return ( await this.db.$queryRaw<{ path: string }[] | null>` SELECT "path" From 314fd7b3e55f5c66f1896a390cdd6621f68651d2 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 13 Jan 2026 14:48:57 +0545 Subject: [PATCH 18/81] fix(OUT-2911): sidebar skeleton layout fixed --- src/app/detail/ui/Sidebar.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/detail/ui/Sidebar.tsx b/src/app/detail/ui/Sidebar.tsx index e351afed7..e4e1b1f85 100644 --- a/src/app/detail/ui/Sidebar.tsx +++ b/src/app/detail/ui/Sidebar.tsx @@ -665,7 +665,7 @@ export const SidebarSkeleton = () => { borderLeft: (theme) => `1px solid ${theme.color.borders.border2}`, height: '100vh', display: showSidebar ? 'block' : 'none', - width: isMobile && showSidebar ? '100vw' : '320px', + width: isMobile && showSidebar ? '100vw' : '305px', }} > @@ -701,6 +701,14 @@ export const SidebarSkeleton = () => { + + + Assignee + + + + + ) From 7a26c7ea723a8fa005ee80d2d0b7957bfe9e6800 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 14 Jan 2026 12:50:34 +0545 Subject: [PATCH 19/81] feat(OUT-2929): disable sending errors to sentry from preview apps --- sentry.client.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry.client.config.ts b/sentry.client.config.ts index 4d755b578..e237e84a0 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -7,6 +7,7 @@ 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 isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' if (dsn) { Sentry.init({ @@ -37,6 +38,9 @@ if (dsn) { ignoreErrors: [/fetch failed/i], beforeSend(event) { + if (isPreview) { + return null + } event.tags = { ...event.tags, // Adding additional app_env tag for cross-checking From 166d3b003c366f710777c2c5b4a86790f753b5d9 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 14 Jan 2026 13:29:25 +0545 Subject: [PATCH 20/81] fix(OUT-2929): cleanup --- sentry.client.config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry.client.config.ts b/sentry.client.config.ts index e237e84a0..12e3d0c7a 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -7,7 +7,6 @@ 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 isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' if (dsn) { Sentry.init({ @@ -38,7 +37,7 @@ if (dsn) { ignoreErrors: [/fetch failed/i], beforeSend(event) { - if (isPreview) { + if (!isProd) { return null } event.tags = { From b1b4960192438a908050659c39677d1dccc860f3 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 14 Jan 2026 13:52:55 +0545 Subject: [PATCH 21/81] fix(OUT-2929): enabled transaction events for preview app in sentry config --- sentry.client.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry.client.config.ts b/sentry.client.config.ts index 12e3d0c7a..65f1c5e43 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -37,7 +37,7 @@ if (dsn) { ignoreErrors: [/fetch failed/i], beforeSend(event) { - if (!isProd) { + if (!isProd && event.type !== 'transaction') { return null } event.tags = { From 28a0d4bcc7bb1617a100b6c6c0ed127f645c30c1 Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Wed, 14 Jan 2026 17:42:04 +0545 Subject: [PATCH 22/81] tryfix: only suppress error type telemetry --- sentry.client.config.ts | 86 ++++++++++++++++++++--------------------- sentry.server.config.ts | 33 ++++++++++------ 2 files changed, 64 insertions(+), 55 deletions(-) diff --git a/sentry.client.config.ts b/sentry.client.config.ts index 65f1c5e43..3c9cb1b7b 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -2,50 +2,50 @@ // 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(), - // 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 !== 'transaction') { - 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(), + // 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 fee202c8c..174077b7b 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -2,22 +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 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; + }, + }); } From f6f2d267d3e76ddd2bdb494ee37984a8a3984ff8 Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Wed, 14 Jan 2026 17:50:58 +0545 Subject: [PATCH 23/81] tryfix: only suppress error type telemetry --- sentry.client.config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sentry.client.config.ts b/sentry.client.config.ts index 3c9cb1b7b..6f7c5ad41 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -25,7 +25,12 @@ if (dsn) { // replaysOnErrorSampleRate: 1.0, // replaysSessionSampleRate: 0, integrations: [ - Sentry.browserTracingIntegration(), + Sentry.browserTracingIntegration({ + beforeStartSpan: (e) => { + console.info("meowmeowmeow", e.name); + return e; + }, + }), // Sentry.replayIntegration({ // Additional Replay configuration goes in here, for example: // maskAllText: true, From 4553aa3a8ad5d0786b2dc1e23639e339ae35e55f Mon Sep 17 00:00:00 2001 From: Rojan Rajbhandari Date: Wed, 14 Jan 2026 17:52:17 +0545 Subject: [PATCH 24/81] tryfix: only suppress error type telemetry --- sentry.client.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry.client.config.ts b/sentry.client.config.ts index 6f7c5ad41..a5d0e6fac 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -27,7 +27,7 @@ if (dsn) { integrations: [ Sentry.browserTracingIntegration({ beforeStartSpan: (e) => { - console.info("meowmeowmeow", e.name); + console.info("SentryBrowserTracingSpan", e.name); return e; }, }), From 60f2780ee6945058b6f1c0337423306c2a9d7b03 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Fri, 16 Jan 2026 12:42:58 +0545 Subject: [PATCH 25/81] fix(OUT-2937): manage template button missing. - endOptions and endOptions href were not being caught simply from ListBoxComponent. Needed to pass the props in a custom manner using ListboxProps and some tweaks. The cause for this might be the version upgrade. - refactored the selector/listBox component solving all 7 incoming warnings. --- src/app/detail/[task_id]/[user_type]/page.tsx | 1 - src/components/inputs/ListboxComponent.tsx | 8 +- src/components/inputs/Selector.tsx | 98 +++++++++++-------- 3 files changed, 63 insertions(+), 44 deletions(-) diff --git a/src/app/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index f4fc414ce..b3a1090aa 100644 --- a/src/app/detail/[task_id]/[user_type]/page.tsx +++ b/src/app/detail/[task_id]/[user_type]/page.tsx @@ -31,7 +31,6 @@ import { HeaderBreadcrumbs } from '@/components/layouts/HeaderBreadcrumbs' import { SilentError } from '@/components/templates/SilentError' import { apiUrl } from '@/config' import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin' -import CustomScrollBar from '@/hoc/CustomScrollBar' import { RealTime } from '@/hoc/RealTime' import { RealTimeTemplates } from '@/hoc/RealtimeTemplates' import { WorkspaceResponse } from '@/types/common' diff --git a/src/components/inputs/ListboxComponent.tsx b/src/components/inputs/ListboxComponent.tsx index 3dffe82e3..d76547a5b 100644 --- a/src/components/inputs/ListboxComponent.tsx +++ b/src/components/inputs/ListboxComponent.tsx @@ -1,4 +1,3 @@ -import CustomScrollBar from '@/hoc/CustomScrollBar' import { Box } from '@mui/material' import { useRouter } from 'next/navigation' @@ -12,7 +11,8 @@ type ListboxComponentProps = React.HTMLAttributes & { } const ListboxComponent = React.forwardRef((props, ref) => { - const { children, endOption, endOptionHref, role } = props + const { children, endOption, endOptionHref, ...otherProps } = props + const router = useRouter() return ( maxHeight: 'none', }} > - - + + {children} {endOption && ( endOptionHref && router.push(endOptionHref)}> diff --git a/src/components/inputs/Selector.tsx b/src/components/inputs/Selector.tsx index dab67dd74..c36987da0 100644 --- a/src/components/inputs/Selector.tsx +++ b/src/components/inputs/Selector.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Popper, Stack, Typography } from '@mui/material' +import { Box, Button, Popper, Stack, SxProps, Theme, Typography } from '@mui/material' import { StyledAutocomplete } from '@/components/inputs/Autocomplete' import { statusIcons } from '@/utils/iconMatcher' import { useFocusableInput } from '@/hooks/useFocusableInput' @@ -276,24 +276,28 @@ export default function Selector({ openOnFocus onKeyDown={handleKeyDown} autoSelect={false} - ListboxProps={{ - sx: { - maxHeight: { - xs: '175px', - sm: '291px', - }, - '& .MuiAutocomplete-option[aria-selected="true"].Mui-focused': { - backgroundColor: (theme) => theme.color.background.bgHover, - }, - '& .MuiAutocomplete-option[aria-selected="true"]': { - backgroundColor: (theme) => theme.color.base.white, - }, - '& .MuiAutocomplete-option.Mui-focused': { - backgroundColor: (theme) => theme.color.background.bgHover, + ListboxProps={ + { + endOption: endOption, + endOptionHref: endOptionHref, + sx: { + maxHeight: { + xs: '175px', + sm: '291px', + }, + '& .MuiAutocomplete-option[aria-selected="true"].Mui-focused': { + backgroundColor: (theme) => theme.color.background.bgHover, + }, + '& .MuiAutocomplete-option[aria-selected="true"]': { + backgroundColor: (theme) => theme.color.base.white, + }, + '& .MuiAutocomplete-option.Mui-focused': { + backgroundColor: (theme) => theme.color.background.bgHover, + }, + padding: '0px', }, - padding: '0px', - }, - }} + } as ExtendedListboxProps + } options={extraOption ? [extraOption, ...processedOptions] : processedOptions} value={value} onChange={(_, newValue: unknown) => { @@ -339,11 +343,11 @@ export default function Selector({ return params.children } return ( - <> + {hasNoAssignee ? ( - {params.children} + {params.children} ) : ( - + ({ {params.children} )} - + ) }} inputValue={inputStatusValue} @@ -396,9 +400,11 @@ export default function Selector({ setAnchorEl(null) setInputStatusValue('') } + const { key, ...otherProps } = props if ((option as IExtraOption).id === 'not_found') { return ( ({ ) } - return extraOption && extraOptionRenderer && (option as IExtraOption)?.extraOptionFlag ? ( - extraOptionRenderer(setAnchorEl, anchorEl, props) - ) : selectorType === SelectorType.ASSIGNEE_SELECTOR ? ( - - ) : selectorType === SelectorType.STATUS_SELECTOR ? ( - - ) : selectorType === SelectorType.TEMPLATE_SELECTOR ? ( - - ) : ( - <> - ) + if (extraOption && extraOptionRenderer && (option as IExtraOption)?.extraOptionFlag) { + return
{extraOptionRenderer(setAnchorEl, anchorEl, otherProps)}
+ } + + if (selectorType === SelectorType.ASSIGNEE_SELECTOR) { + return + } + + if (selectorType === SelectorType.STATUS_SELECTOR) { + return + } + + if (selectorType === SelectorType.TEMPLATE_SELECTOR) { + return ( + + ) + } + + return <> }} noOptionsText={endOption && } /> @@ -453,7 +468,6 @@ const TemplateSelectorRenderer = ({ }) => { return ( & { + sx?: SxProps + endOption?: React.ReactNode + endOptionHref?: string +} From f109e2e40502644328d8bd6139524d76d82d12dd Mon Sep 17 00:00:00 2001 From: Prios Shrestha <30313649+priosshrsth@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:38:09 +0545 Subject: [PATCH 26/81] fix(out-2943): fix alignment of search and filter icons (#1095) * fix(out-2943): fix alignment of search and filter icons --- src/app/ui/NewTaskForm.tsx | 2 +- src/components/layouts/FilterBar.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/ui/NewTaskForm.tsx b/src/app/ui/NewTaskForm.tsx index 2b99cd1e3..cf768f35e 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -288,7 +288,7 @@ export const NewTaskForm = ({ handleCreate, handleClose }: NewTaskFormProps) => { Date: Mon, 19 Jan 2026 14:22:00 +0545 Subject: [PATCH 27/81] fix(OUT-2944): enabled feature flag to retry assembly api calls on 404 errors on withRetry --- src/app/api/core/utils/withRetry.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/api/core/utils/withRetry.ts b/src/app/api/core/utils/withRetry.ts index 2ac2638ea..dbc9a2d4f 100644 --- a/src/app/api/core/utils/withRetry.ts +++ b/src/app/api/core/utils/withRetry.ts @@ -2,6 +2,8 @@ import { StatusableError } from '@/types/CopilotApiError' import pRetry, { FailedAttemptError } from 'p-retry' import * as Sentry from '@sentry/nextjs' +export const RETRY_404_ENABLED = process.env.RETRY_404 === 'true' + export const withRetry = async (fn: (...args: any[]) => Promise, args: any[]): Promise => { let isEventProcessorRegistered = false @@ -41,7 +43,11 @@ export const withRetry = async (fn: (...args: any[]) => Promise, args: any // Typecasting because Copilot doesn't export an error class const err = error as StatusableError // Retry if statusCode is 429 (ratelimit), 408 (timeouts), or any server related (5xx) error - return [408, 429].includes(err.status) || (err.status >= 500 && err.status <= 511) + return ( + [408, 429].includes(err.status) || + (err.status >= 500 && err.status <= 511) || + (RETRY_404_ENABLED && err.status === 404) + ) }, }, ) From e838a6226b10aaa34188f2b4eed8b0f3ed2cc711 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 22 Jan 2026 11:09:17 +0545 Subject: [PATCH 28/81] fix(OUT-2944): instantiate copilotAPI if apikey mismatch --- src/app/api/core/services/base.service.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/app/api/core/services/base.service.ts b/src/app/api/core/services/base.service.ts index 1a66e0baa..84759ad5e 100644 --- a/src/app/api/core/services/base.service.ts +++ b/src/app/api/core/services/base.service.ts @@ -6,6 +6,7 @@ import { PrismaClient } from '@prisma/client' declare global { var copilot: CopilotAPI | undefined var token: string | undefined + var copilotApiKey: string | undefined } /** @@ -21,15 +22,12 @@ export class BaseService { this.user = user this.customApiKey = customCopilotApiKey - // Set a global token if not present. We use token as an identifier to issue a new SDK instance - if (!globalThis.token) { - globalThis.token = user.token - } - - // If token mismatches, or global copilot instance is not present, create a new one. + // If token or copiltoApiKey mismatches, or global copilot instance is not present, create a new one. // INFO: The token mismatch check is to make sure that fluid compute sharing serverless functions doesn't reuse the same SDK instance - if (globalThis.token !== user.token || !globalThis.copilot) { - globalThis.copilot = new CopilotAPI(user.token) + if (globalThis.token !== user.token || globalThis.copilotApiKey !== customCopilotApiKey || !globalThis.copilot) { + globalThis.token = user.token + globalThis.copilotApiKey = customCopilotApiKey + globalThis.copilot = new CopilotAPI(user.token, customCopilotApiKey) } this.copilot = globalThis.copilot From f38eca21e928da225d3b2265def1772089fe2106 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Fri, 23 Jan 2026 14:19:00 +0545 Subject: [PATCH 29/81] fix(OUT-2944-re): removed globalThis on base service - globalThis was causing copilot instance to be shared, swapping global instances while calling copilot methods. - The solution applied verifies a unique SDK instance for each service instance. --- src/app/api/core/services/base.service.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/app/api/core/services/base.service.ts b/src/app/api/core/services/base.service.ts index 84759ad5e..0ffcea051 100644 --- a/src/app/api/core/services/base.service.ts +++ b/src/app/api/core/services/base.service.ts @@ -3,12 +3,6 @@ import { CopilotAPI } from '@/utils/CopilotAPI' import User from '@api/core/models/User.model' import { PrismaClient } from '@prisma/client' -declare global { - var copilot: CopilotAPI | undefined - var token: string | undefined - var copilotApiKey: string | undefined -} - /** * Base Service with access to db and current user */ @@ -21,16 +15,7 @@ export class BaseService { constructor(user: User, customCopilotApiKey?: string) { this.user = user this.customApiKey = customCopilotApiKey - - // If token or copiltoApiKey mismatches, or global copilot instance is not present, create a new one. - // INFO: The token mismatch check is to make sure that fluid compute sharing serverless functions doesn't reuse the same SDK instance - if (globalThis.token !== user.token || globalThis.copilotApiKey !== customCopilotApiKey || !globalThis.copilot) { - globalThis.token = user.token - globalThis.copilotApiKey = customCopilotApiKey - globalThis.copilot = new CopilotAPI(user.token, customCopilotApiKey) - } - - this.copilot = globalThis.copilot + this.copilot = new CopilotAPI(user.token) } setTransaction(tx: PrismaClient) { From 4a2b05a7e3e4ca16bb22a888a76866ce4054f90e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 27 Jan 2026 15:57:40 +0545 Subject: [PATCH 30/81] 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 de05282b149a022e4f65388b096efae6c3601405 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Thu, 15 Jan 2026 16:28:33 +0545 Subject: [PATCH 31/81] 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 | 10 +- src/components/cards/CommentCard.tsx | 9 +- src/components/cards/ReplyCard.tsx | 2 +- src/components/inputs/CommentInput.tsx | 12 +-- src/components/inputs/ReplyInput.tsx | 2 +- 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, 209 insertions(+), 114 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 6b60a9112..9964b294f 100644 --- a/src/app/detail/ui/Comments.tsx +++ b/src/app/detail/ui/Comments.tsx @@ -9,6 +9,7 @@ import { OptimisticUpdate } from '@/utils/optimisticCommentUtils' import { TrashIcon2 } from '@/icons' import { useSelector } from 'react-redux' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' +import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' interface Prop { comment: LogResponse @@ -17,9 +18,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 ( @@ -47,6 +57,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 cf768f35e..5a56afbf7 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -17,6 +17,7 @@ import { useHandleSelectorComponent } from '@/hooks/useHandleSelectorComponent' import { PersonIconSmall, CloseIcon, TempalteIconMd, AssigneePlaceholderSmall } from '@/icons' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { + addAttachment, selectCreateTask, setAllCreateTaskFields, setAppliedDescription, @@ -41,7 +42,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,7 @@ 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 @@ -558,7 +560,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 5f7418151..c8bc1592a 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 dc18e3d6d..01c6f82c7 100644 --- a/src/components/inputs/CommentInput.tsx +++ b/src/components/inputs/CommentInput.tsx @@ -8,13 +8,12 @@ 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 { selectTaskDetails } from '@/redux/features/taskDetailsSlice' +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 { ArrowUpward } from '@mui/icons-material' -import { Box, IconButton, InputAdornment, Stack } from '@mui/material' +import { Stack } from '@mui/material' import { useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { Tapwrite } from 'tapwrite' @@ -22,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) @@ -88,7 +88,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 33d31628c..c418ce589 100644 --- a/src/components/inputs/ReplyInput.tsx +++ b/src/components/inputs/ReplyInput.tsx @@ -10,7 +10,7 @@ import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { CreateComment } from '@/types/dto/comment.dto' import { getMentionsList } from '@/utils/getMentionList' -import { deleteEditorAttachmentsHandler } from '@/utils/inlineImage' +import { deleteEditorAttachmentsHandler } from '@/utils/attachmentUtils' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { Avatar, Box, InputAdornment, 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 1ade7fd4b77e05d9e11a103c0faf89a120da7aa0 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Fri, 16 Jan 2026 17:15:48 +0545 Subject: [PATCH 32/81] 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 | 13 +-- 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, 154 insertions(+), 39 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 5a56afbf7..310d55ce6 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -1,6 +1,8 @@ +import { UserRole } from '@/app/api/core/types/user' import { PublicTaskCreateDto } from '@/app/api/tasks/public/public.dto' import { CopilotAvatar } from '@/components/atoms/CopilotAvatar' import AttachmentLayout from '@/components/AttachmentLayout' +import { GhostBtn } from '@/components/buttons/GhostBtn' import { ManageTemplatesEndOption } from '@/components/buttons/ManageTemplatesEndOptions' import { PrimaryBtn } from '@/components/buttons/PrimaryBtn' import { SecondaryBtn } from '@/components/buttons/SecondaryBtn' @@ -14,10 +16,9 @@ import { StyledTextField } from '@/components/inputs/TextField' import { MAX_UPLOAD_LIMIT } from '@/constants/attachments' import { AppMargin, SizeofAppMargin } from '@/hoc/AppMargin' import { useHandleSelectorComponent } from '@/hooks/useHandleSelectorComponent' -import { PersonIconSmall, CloseIcon, TempalteIconMd, AssigneePlaceholderSmall } from '@/icons' +import { CloseIcon, PersonIconSmall, TempalteIconMd } from '@/icons' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { - addAttachment, selectCreateTask, setAllCreateTaskFields, setAppliedDescription, @@ -41,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, @@ -50,14 +51,11 @@ import { getSelectorAssigneeFromFilterOptions, } from '@/utils/selector' import { trimAllTags } from '@/utils/trimTags' -import { Box, Stack, Typography, styled } from '@mui/material' +import { Box, Stack, styled, Typography } from '@mui/material' 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 +560,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 f61fb7c9e085d5773d27dbe8a6c095445f0a016e Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 19 Jan 2026 19:23:29 +0545 Subject: [PATCH 33/81] 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 9964b294f..03c636920 100644 --- a/src/app/detail/ui/Comments.tsx +++ b/src/app/detail/ui/Comments.tsx @@ -12,6 +12,7 @@ import { selectTaskBoard } from '@/redux/features/taskBoardSlice' 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 @@ -22,6 +23,7 @@ interface Prop { } export const Comments = ({ + token, comment, createComment, deleteComment, @@ -50,6 +52,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 310d55ce6..be888a51f 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, @@ -558,10 +559,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 c8bc1592a..23b46458d 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) @@ -86,13 +88,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 c418ce589..bd81406af 100644 --- a/src/components/inputs/ReplyInput.tsx +++ b/src/components/inputs/ReplyInput.tsx @@ -16,26 +16,27 @@ import { Avatar, Box, InputAdornment, 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 @@ -146,6 +147,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 1a9e5010a1be7e498d1f78213c1429faaba772cf Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 19 Jan 2026 19:23:57 +0545 Subject: [PATCH 34/81] 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 685c684f59f38e99eb5ff49978526b38595272e8 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 20 Jan 2026 12:52:20 +0545 Subject: [PATCH 35/81] 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 | 15 +------- 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(+), 138 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 03c636920..3386d9710 100644 --- a/src/app/detail/ui/Comments.tsx +++ b/src/app/detail/ui/Comments.tsx @@ -1,7 +1,6 @@ import { CopilotAvatar } from '@/components/atoms/CopilotAvatar' import { CommentCard } from '@/components/cards/CommentCard' import { CreateComment } from '@/types/dto/comment.dto' -import { IAssigneeCombined } from '@/types/interfaces' import { LogResponse } from '@api/activity-logs/schemas/LogResponseSchema' import { Stack } from '@mui/material' import { VerticalLine } from './styledComponent' @@ -9,7 +8,6 @@ import { OptimisticUpdate } from '@/utils/optimisticCommentUtils' import { TrashIcon2 } from '@/icons' import { useSelector } from 'react-redux' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' -import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' interface Prop { token: string @@ -19,19 +17,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 ( @@ -60,7 +48,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 be888a51f..016ea5790 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -557,13 +557,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 23b46458d..b40801ba7 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 cda412cad..3d0995d9c 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) @@ -87,13 +86,11 @@ export const CommentInput = ({ createComment, task_id, postAttachment, token }: } }, [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 bd81406af..3deb1bf01 100644 --- a/src/components/inputs/ReplyInput.tsx +++ b/src/components/inputs/ReplyInput.tsx @@ -147,14 +147,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 8be8e9d45c9e9c70024e203cb8c568e0d996142d Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 20 Jan 2026 15:07:45 +0545 Subject: [PATCH 36/81] 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 3116f9dbbca40098dc5ba22477e6ba5dcf3f2d5f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 14 Jan 2026 19:52:14 +0545 Subject: [PATCH 37/81] 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 b263def9a85bb412c8c3d0c01ef4e3005b413dfc Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 14 Jan 2026 20:24:20 +0545 Subject: [PATCH 38/81] 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 b7dcd9756a6fa1e2c225ce4e689b104ababfc2dc Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 12:56:54 +0545 Subject: [PATCH 39/81] 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 16a8565cf867b663dbab91f3bd7595cebbd3b71b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 16:20:10 +0545 Subject: [PATCH 40/81] 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 7be51f806a32b63ce3c3b77ee5272df93c2376c2 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 16:20:53 +0545 Subject: [PATCH 41/81] 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 e200100008a96c1f6fb74365164a9e572ac35479 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 17:09:30 +0545 Subject: [PATCH 42/81] 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 c540cacc2f702ae9eb6a588dcb03f7fad76eb447 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 12:21:36 +0545 Subject: [PATCH 43/81] 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 9ae1fcfa371bcb290b2c95534d37dbf0d3637260 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 12:59:16 +0545 Subject: [PATCH 44/81] 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 9921f345caf1cfb2807e39c7ed8f7e7c88a86db8 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 17:39:39 +0545 Subject: [PATCH 45/81] 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 8f32709d9a8909cf1279b7d93ad647bd8836081b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 14 Jan 2026 19:52:14 +0545 Subject: [PATCH 46/81] 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 a7ddc272b786bc4e49097833ca5454b28048fa76 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 19:16:42 +0545 Subject: [PATCH 47/81] 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 2fe01142e1e6053132c6da1cad8eb5f0f2f2521f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 21 Jan 2026 14:01:36 +0545 Subject: [PATCH 48/81] 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 e85fa68e4e493c5e383aa2d3d0367b60ecd03286 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 21 Jan 2026 14:04:41 +0545 Subject: [PATCH 49/81] 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 a1bd130d27e33944f81811d96c581445b0660cad Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 21 Jan 2026 16:58:53 +0545 Subject: [PATCH 50/81] 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 823528fec0b744e7180b9fbb8a3d1a156b1ccce7 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 15 Jan 2026 19:16:42 +0545 Subject: [PATCH 51/81] 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 9efad99f79ee3927a14d54d9c28938261364f948 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 16 Jan 2026 17:56:10 +0545 Subject: [PATCH 52/81] 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 a360a614dda73ce6447870be67617177ac3cb40e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 21 Jan 2026 14:32:53 +0545 Subject: [PATCH 53/81] 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 09d7826df71e8c9c96ba1964a0e45235a2f85038 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 22 Jan 2026 16:08:26 +0545 Subject: [PATCH 54/81] 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 ab40343a264331e53d076554de3023ec793c9a57 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 19 Jan 2026 13:30:07 +0545 Subject: [PATCH 55/81] 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 19b8cf937a5a32df808a52a14851fe6b648f9b48 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 19 Jan 2026 14:13:48 +0545 Subject: [PATCH 56/81] 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 22a856855ab5c07d6847f5fa25c2d03c6d53171a Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 12:07:34 +0545 Subject: [PATCH 57/81] 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 de058a85cd89fa26f355e23f6744d29974da8ae3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 19 Jan 2026 16:10:21 +0545 Subject: [PATCH 58/81] 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 13c153f44f8f93e84200497442a6385e851159d6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 19 Jan 2026 16:35:23 +0545 Subject: [PATCH 59/81] 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 2d96d8f4e4526e6cdf06c0f6c319cf0c080cb35b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 19 Jan 2026 16:38:31 +0545 Subject: [PATCH 60/81] 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 6e9fc0b7512b1547ac108aa2c88c76e6bd3a113a Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 22 Jan 2026 15:41:12 +0545 Subject: [PATCH 61/81] 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 2b3c037780270cd611f1653dc8d23e06c73e0989 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 22 Jan 2026 16:10:41 +0545 Subject: [PATCH 62/81] 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 6cfb215a0966e9bac156949150e8d1dd05a20817 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 22 Jan 2026 17:05:18 +0545 Subject: [PATCH 63/81] 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 746c026c276d2fde88d29eb7efa95f2b7a4afd10 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 12:20:47 +0545 Subject: [PATCH 64/81] 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 66b9a9522c66d87cbc7209622869c9329380f4a8 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 12:30:57 +0545 Subject: [PATCH 65/81] 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 63f4e81d03eab7840992c0bc1c40a8f24d1e625b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 13:51:16 +0545 Subject: [PATCH 66/81] 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 6394e924e7806b073c472e0c621d133147a4c7e9 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 14:22:46 +0545 Subject: [PATCH 67/81] 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 98b2457c47b0c7eafbc6086bdcf004d661f94857 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 15:50:22 +0545 Subject: [PATCH 68/81] 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 cdd4a588151e0439616d24a4e8da724642104a5b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 26 Jan 2026 15:52:34 +0545 Subject: [PATCH 69/81] 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 e40835eeef57e541c5ed41f2e93e49a32e89060a Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 27 Jan 2026 15:08:57 +0545 Subject: [PATCH 70/81] 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 a1d3a9de45aac6237f2cf05f32e0ce716a196ed4 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 27 Jan 2026 16:03:00 +0545 Subject: [PATCH 71/81] 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 701d5387fe57c50e71326d8ebeb59ce6f9545d84 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 27 Jan 2026 16:03:21 +0545 Subject: [PATCH 72/81] 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 82642b2446fe9cf486ad0ce22ff344d9c33cb913 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 28 Jan 2026 09:27:24 +0545 Subject: [PATCH 73/81] 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 af47c14005dde10a43a504050533930056d28a6a Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 27 Jan 2026 15:55:26 +0545 Subject: [PATCH 74/81] 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 b7fc575ef760ae0dac60ab0c6026719c742a1097 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 27 Jan 2026 16:10:43 +0545 Subject: [PATCH 75/81] 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 e6d66730682b5dc6da241259b6f2c38792c2acaf Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 28 Jan 2026 16:13:01 +0545 Subject: [PATCH 76/81] 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 3f1bd1b504eea8a350a9afc3c2033115228d4918 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 28 Jan 2026 16:37:55 +0545 Subject: [PATCH 77/81] 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 4936609c1b6831375f0b7ae5a1403fbf4150d3ff Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 28 Jan 2026 13:36:30 +0545 Subject: [PATCH 78/81] 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 1af40c850..a600c9ecf 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,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 268658b8f3e3c22342ee398f5d2d7b3a6ca25027 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 28 Jan 2026 17:15:19 +0545 Subject: [PATCH 79/81] 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 18e76be159bededc6dda05899c537ee63b2761d8 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Fri, 30 Jan 2026 13:11:13 +0545 Subject: [PATCH 80/81] 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 79944d3c132f56cfd07df1eb4922851aa8ec8c59 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Fri, 6 Feb 2026 12:58:09 +0545 Subject: [PATCH 81/81] fix(OUT-3060): sub task title in client board truncated very early --- src/app/detail/ui/TaskCardList.tsx | 8 ++------ src/components/atoms/TaskTitle.tsx | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/app/detail/ui/TaskCardList.tsx b/src/app/detail/ui/TaskCardList.tsx index b89037623..fc5edab18 100644 --- a/src/app/detail/ui/TaskCardList.tsx +++ b/src/app/detail/ui/TaskCardList.tsx @@ -259,11 +259,7 @@ export const TaskCardList = ({ flexShrink: 1, }} > - + {(task.subtaskCount > 0 || task.isArchived) && ( @@ -316,7 +312,7 @@ export const TaskCardList = ({ }} > {task.dueDate && ( - + { const isoDate = DateStringSchema.parse(formatDate(date)) diff --git a/src/components/atoms/TaskTitle.tsx b/src/components/atoms/TaskTitle.tsx index 3a6a135ce..3d100ec08 100644 --- a/src/components/atoms/TaskTitle.tsx +++ b/src/components/atoms/TaskTitle.tsx @@ -5,10 +5,9 @@ import { CopilotTooltip } from './CopilotTooltip' interface TaskTitleProps { title?: string variant?: 'board' | 'list' | 'subtasks' - isClient?: boolean } -const TaskTitle = ({ title, variant = 'board', isClient = false }: TaskTitleProps) => { +const TaskTitle = ({ title, variant = 'board' }: TaskTitleProps) => { const textRef = useRef(null) const [isOverflowing, setIsOverflowing] = useState(false) @@ -50,7 +49,6 @@ const TaskTitle = ({ title, variant = 'board', isClient = false }: TaskTitleProp flexShrink: 1, flexGrow: 0, minWidth: 0, - maxWidth: isClient ? '105px' : 'none', }} > {title}