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/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 2e43850a0..64426999d 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 '@api/attachments/attachments.service' +import { getSignedUrl } from '@/utils/signUrl' export class CommentService extends BaseService { async create(data: CreateComment) { @@ -44,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( @@ -266,4 +289,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 + } //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 d5ca9abf8..5b07daac6 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) + if (createAttachmentPayloads.length) { + const attachmentService = new AttachmentsService(this.user) + await attachmentService.createMultipleAttachments(createAttachmentPayloads) + } const signedUrlPromises = newFilePaths.map(async ({ originalSrc, newFilePath }) => { const newUrl = await getSignedUrl(newFilePath) 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/detail/[task_id]/[user_type]/page.tsx b/src/app/detail/[task_id]/[user_type]/page.tsx index b3a1090aa..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,8 +214,14 @@ export default async function TaskDetailPage(props: { canCreateSubtasks={params.user_type === UserType.INTERNAL_USER || !!getPreviewMode(tokenPayload)} /> )} - - + { + 'use server' + await postAttachment(token, postAttachmentPayload) + }} + > + + {item.type === ActivityType.COMMENT_ADDED ? ( @@ -242,7 +244,7 @@ export const ActivityWrapper = ({ ))} - + )} diff --git a/src/app/detail/ui/Comments.tsx b/src/app/detail/ui/Comments.tsx index 6b60a9112..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' @@ -11,6 +10,7 @@ import { useSelector } from 'react-redux' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' interface Prop { + token: string comment: LogResponse createComment: (postCommentPayload: CreateComment) => void deleteComment: (id: string, replyId?: string, softDelete?: boolean) => void @@ -19,7 +19,7 @@ interface Prop { optimisticUpdates: OptimisticUpdate[] } -export const Comments = ({ comment, createComment, deleteComment, task_id, stableId, optimisticUpdates }: 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 ( @@ -40,6 +40,7 @@ export const Comments = ({ comment, createComment, deleteComment, task_id, stabl /> uploadImageHandler(file, token, tokenPayload.workspaceId, null) - : 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 edca9e837..e264e5a07 100644 --- a/src/app/detail/ui/TaskEditor.tsx +++ b/src/app/detail/ui/TaskEditor.tsx @@ -14,11 +14,12 @@ 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' import { Tapwrite } from 'tapwrite' +import { createUploadFn } from '@/utils/createUploadFn' interface Prop { task_id: string @@ -135,14 +136,16 @@ export const TaskEditor = ({ debouncedResetTypingFlag() } - const uploadFn = token - ? async (file: File) => { - setActiveUploads((prev) => prev + 1) - const fileUrl = await uploadImageHandler(file, token ?? '', task.workspaceId, task_id) - setActiveUploads((prev) => prev - 1) - return fileUrl - } - : 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 e83fd6d2d..919afd292 100644 --- a/src/app/manage-templates/ui/NewTemplateCard.tsx +++ b/src/app/manage-templates/ui/NewTemplateCard.tsx @@ -13,7 +13,9 @@ 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 { 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' @@ -60,10 +62,11 @@ export const NewTemplateCard = ({ [field]: value, })) } - const uploadFn = - token && tokenPayload?.workspaceId - ? (file: File) => uploadImageHandler(file, token, tokenPayload.workspaceId, null, 'templates') - : 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 aa3fe5103..b9d6dfcba 100644 --- a/src/app/manage-templates/ui/TemplateDetails.tsx +++ b/src/app/manage-templates/ui/TemplateDetails.tsx @@ -10,8 +10,9 @@ 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 { deleteEditorAttachmentsHandler, uploadImageHandler } from '@/utils/inlineImage' +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' @@ -109,14 +110,14 @@ export default function TemplateDetails({ debouncedResetTypingFlag() } - const uploadFn = token - ? async (file: File) => { - setActiveUploads((prev) => prev + 1) - const fileUrl = await uploadImageHandler(file, token ?? '', template.workspaceId, template_id, 'templates') - setActiveUploads((prev) => prev - 1) - return fileUrl - } - : 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 ac902a706..b5ff8d4f5 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 { @@ -25,9 +25,10 @@ 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' +import { createUploadFn } from '@/utils/createUploadFn' export const TemplateForm = ({ handleCreate }: { handleCreate: () => void }) => { const { workflowStates, assignee } = useSelector(selectTaskBoard) @@ -82,10 +83,11 @@ const NewTemplateFormInputs = () => { const { workflowStates, token } = useSelector(selectTaskBoard) const { tokenPayload } = useSelector(selectAuthDetails) - const uploadFn = - token && tokenPayload?.workspaceId - ? async (file: File) => uploadImageHandler(file, token, tokenPayload.workspaceId, null, 'templates') - : 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/Modal_NewTaskForm.tsx b/src/app/ui/Modal_NewTaskForm.tsx index 043958f98..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, @@ -93,16 +92,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..016ea5790 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,7 +16,7 @@ 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 { selectCreateTask, @@ -40,8 +42,9 @@ import { UserIds, } 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 { deleteEditorAttachmentsHandler, uploadImageHandler } from '@/utils/inlineImage' import { getSelectedUserIds, getSelectedViewerIds, @@ -49,13 +52,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' interface NewTaskFormInputsProps { isEditorReadonly?: boolean @@ -556,10 +557,10 @@ const NewTaskFormInputs = ({ isEditorReadonly }: NewTaskFormInputsProps) => { store.dispatch(setCreateTaskFields({ targetField: 'description', value: content })) } - const uploadFn = - token && tokenPayload?.workspaceId - ? (file: File) => uploadImageHandler(file, token, tokenPayload.workspaceId, null) - : undefined + const uploadFn = createUploadFn({ + token, + workspaceId: tokenPayload?.workspaceId, + }) return ( <> diff --git a/src/components/cards/CommentCard.tsx b/src/components/cards/CommentCard.tsx index 8b26ac56c..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' @@ -26,11 +27,12 @@ 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 { deleteEditorAttachmentsHandler, getAttachmentPayload } from '@/utils/attachmentUtils' +import { createUploadFn } from '@/utils/createUploadFn' import { fetcher } from '@/utils/fetcher' import { getTimeDifference } from '@/utils/getTimeDifference' -import { deleteEditorAttachmentsHandler, uploadImageHandler } from '@/utils/inlineImage' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { checkOptimisticStableId, OptimisticUpdate } from '@/utils/optimisticCommentUtils' import { ReplyResponse } from '@api/activity-logs/schemas/CommentAddedSchema' @@ -44,6 +46,7 @@ import { Tapwrite } from 'tapwrite' import { z } from 'zod' export const CommentCard = ({ + token, comment, createComment, deleteComment, @@ -52,6 +55,7 @@ export const CommentCard = ({ commentInitiator, 'data-comment-card': dataCommentCard, //for selection of the element while highlighting the container in notification }: { + token: string comment: LogResponse createComment: (postCommentPayload: CreateComment) => void deleteComment: (id: string, replyId?: string, softDelete?: boolean) => void @@ -72,7 +76,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) @@ -80,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 @@ -105,14 +111,23 @@ export const CommentCard = ({ return () => clearInterval(intervalId) }, [comment.createdAt]) - const uploadFn = token - ? async (file: File) => { - if (activeTask) { - const fileUrl = await uploadImageHandler(file, token, activeTask.workspaceId, task_id) - return fileUrl - } - } - : undefined + 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 = 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) @@ -352,8 +367,8 @@ export const CommentCard = ({ return ( 0) || showReply ? ( diff --git a/src/components/cards/ReplyCard.tsx b/src/components/cards/ReplyCard.tsx index 5f7418151..b40801ba7 100644 --- a/src/components/cards/ReplyCard.tsx +++ b/src/components/cards/ReplyCard.tsx @@ -11,15 +11,17 @@ 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' 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 { deleteEditorAttachmentsHandler, getAttachmentPayload } from '@/utils/attachmentUtils' +import { createUploadFn } from '@/utils/createUploadFn' import { getTimeDifference } from '@/utils/getTimeDifference' -import { deleteEditorAttachmentsHandler } from '@/utils/inlineImage' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { Box, Stack } from '@mui/material' import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' @@ -28,16 +30,16 @@ import { Tapwrite } from 'tapwrite' import { z } from 'zod' export const ReplyCard = ({ + token, item, - uploadFn, task_id, handleImagePreview, deleteReply, setDeletedReplies, replyInitiator, }: { + token: string item: ReplyResponse - uploadFn: ((file: File) => Promise) | undefined task_id: string handleImagePreview: (e: React.MouseEvent) => void deleteReply: (id: string, replyId: string) => void @@ -47,7 +49,7 @@ export const ReplyCard = ({ 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 +59,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 = () => { @@ -75,6 +83,8 @@ export const ReplyCard = ({ const canDelete = tokenPayload?.internalUserId == item?.initiatorId + const { postAttachment } = usePostAttachment() + const handleEdit = async () => { if (isTapwriteContentEmpty(editedContent)) { setEditedContent(content) @@ -113,6 +123,18 @@ export const ReplyCard = ({ } }, [editedContent, isListOrMenuActive, isFocused, isMobile]) + 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 ( <> void task_id: string } -export const CommentInput = ({ createComment, task_id }: Prop) => { +export const CommentInput = ({ createComment, task_id, 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) @@ -85,14 +86,12 @@ export const CommentInput = ({ createComment, task_id }: 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 uploadImageHandler(file, token ?? '', activeTask.workspaceId, task_id) - return fileUrl - } - } - : 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 33d31628c..3deb1bf01 100644 --- a/src/components/inputs/ReplyInput.tsx +++ b/src/components/inputs/ReplyInput.tsx @@ -10,32 +10,33 @@ 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' 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,12 @@ export const ReplyInput = ({ dragCounter.current = 0 } + const uploadFn = createUploadFn({ + token, + workspaceId: activeTask?.workspaceId, + getEntityId: () => task_id, + }) + return ( <> 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/redux/features/createTaskSlice.ts b/src/redux/features/createTaskSlice.ts index 0e938a388..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,11 +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) - }, - setCreateTaskFields: ( state, action: { payload: { targetField: keyof IInitialState; value: IInitialState[keyof IInitialState] } }, @@ -109,7 +102,6 @@ const createTaskSlice = createSlice({ } } state.viewers = [] - state.attachments = [] state.dueDate = null state.errors = { [CreateTaskErrors.TITLE]: false, @@ -142,7 +134,6 @@ export const { setActiveWorkflowStateId, setCreateTaskFields, clearCreateTaskFields, - removeOneAttachment, setErrors, setAppliedDescription, setAppliedTitle, 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/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/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 51% rename from src/utils/inlineImage.ts rename to src/utils/attachmentUtils.ts index cd9af1a4b..d2ff7edad 100644 --- a/src/utils/inlineImage.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' @@ -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) => { - if (type === 'tasks') { +const buildFilePath = ( + workspaceId: string, + type: AttachmentTypes[keyof AttachmentTypes], + entityId: string | null, + parentTaskId?: string, +) => { + if (type === AttachmentTypes.TASK) { return entityId ? `/${workspaceId}/${entityId}` : `/${workspaceId}` + } else if (type === AttachmentTypes.COMMENT) { + 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: AttachmentTypes[keyof AttachmentTypes] = AttachmentTypes.TASK, + 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,27 @@ export const deleteEditorAttachmentsHandler = async ( postScrapMedia(token, payload) } } + +export const getAttachmentPayload = ( + fileUrl: string, + file: File, + id: string, + entity: AttachmentTypes[keyof AttachmentTypes] = AttachmentTypes.TASK, +) => { + const filePath = getFilePathFromUrl(fileUrl) + + const payload = entity === AttachmentTypes.COMMENT ? { 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] || '' +} diff --git a/src/utils/createUploadFn.ts b/src/utils/createUploadFn.ts new file mode 100644 index 000000000..80679d721 --- /dev/null +++ b/src/utils/createUploadFn.ts @@ -0,0 +1,41 @@ +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 + if (!config.token || !config.workspaceId) { + return undefined + } + 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?.() + } + } +}