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?.()
+ }
+ }
+}