diff --git a/package.json b/package.json index 33f5f72b0..05e3c500b 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,8 @@ "cmd:delete-duplicate-notifications": "tsx ./src/cmd/delete-duplicate-notifications", "cmd:normalize-filterOptions-assignee": "tsx ./src/cmd/normalize-filterOptions-assignee", "cmd:post-deploy-m15": "tsx ./src/cmd/post-deploy-m15", + "cmd:backfill-attachments": "tsx ./src/cmd/backfill-attachments", + "cmd:backfill-initiatorType-in-comments": "tsx ./src/cmd/backfill-initiatorType-in-comments", "db:grant-supabase-privileges": "node src/lib/supabase-privilege", "deploy": "npx trigger.dev@latest deploy", "dev": "next dev", diff --git a/prisma/migrations/20260115090155_add_created_at_task_id_worspace_id_index_on_comment/migration.sql b/prisma/migrations/20260115090155_add_created_at_task_id_worspace_id_index_on_comment/migration.sql new file mode 100644 index 000000000..7e6ad8d26 --- /dev/null +++ b/prisma/migrations/20260115090155_add_created_at_task_id_worspace_id_index_on_comment/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "IX_Comments_taskId_workspaceId_createdAt" ON "Comments"("taskId", "workspaceId", "createdAt" DESC); diff --git a/prisma/schema/comment.prisma b/prisma/schema/comment.prisma index d2cdba4df..430c3ac68 100644 --- a/prisma/schema/comment.prisma +++ b/prisma/schema/comment.prisma @@ -22,4 +22,5 @@ model Comment { deletedAt DateTime? @db.Timestamptz() @@map("Comments") + @@index([taskId, workspaceId, createdAt(sort: Desc)], name: "IX_Comments_taskId_workspaceId_createdAt") } diff --git a/sentry.client.config.ts b/sentry.client.config.ts index a5d0e6fac..5260484fc 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -27,8 +27,8 @@ if (dsn) { integrations: [ Sentry.browserTracingIntegration({ beforeStartSpan: (e) => { - console.info("SentryBrowserTracingSpan", e.name); - return e; + console.info('SentryBrowserTracingSpan', e.name) + return e }, }), // Sentry.replayIntegration({ @@ -43,14 +43,14 @@ if (dsn) { beforeSend(event) { if (!isProd && event.type === undefined) { - return null; + return null } event.tags = { ...event.tags, // Adding additional app_env tag for cross-checking - app_env: isProd ? "production" : vercelEnv || "development", - }; - return event; + app_env: isProd ? 'production' : vercelEnv || 'development', + } + return event }, - }); + }) } diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 174077b7b..8c6accff6 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -4,9 +4,9 @@ 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({ @@ -24,9 +24,9 @@ if (dsn) { beforeSend(event) { if (!isProd && event.type === undefined) { - return null; + return null } - return event; + return event }, - }); + }) } diff --git a/src/app/api/activity-logs/services/activity-log.service.ts b/src/app/api/activity-logs/services/activity-log.service.ts index a5faa4fb6..c83cd6cec 100644 --- a/src/app/api/activity-logs/services/activity-log.service.ts +++ b/src/app/api/activity-logs/services/activity-log.service.ts @@ -9,7 +9,7 @@ import { SchemaByActivityType, } from '@api/activity-logs/const' import { LogResponse, LogResponseSchema } from '@api/activity-logs/schemas/LogResponseSchema' -import { CommentService } from '@api/comment/comment.service' +import { CommentService } from '@/app/api/comments/comment.service' import APIError from '@api/core/exceptions/api' import User from '@api/core/models/User.model' import { BaseService } from '@api/core/services/base.service' diff --git a/src/app/api/attachments/attachments.service.ts b/src/app/api/attachments/attachments.service.ts index 256db7c7c..455dfb839 100644 --- a/src/app/api/attachments/attachments.service.ts +++ b/src/app/api/attachments/attachments.service.ts @@ -9,6 +9,7 @@ import APIError from '@api/core/exceptions/api' import httpStatus from 'http-status' import { SupabaseService } from '@api/core/services/supabase.service' import { signedUrlTtl } from '@/constants/attachments' +import { PrismaClient } from '@prisma/client' export class AttachmentsService extends BaseService { async getAttachments(taskId: string) { @@ -30,7 +31,7 @@ export class AttachmentsService extends BaseService { const newAttachment = await this.db.attachment.create({ data: { ...data, - createdById: z.string().parse(this.user.internalUserId), + createdById: z.string().parse(this.user.internalUserId || this.user.clientId), // CU are also allowed to create attachments workspaceId: this.user.workspaceId, }, }) @@ -40,7 +41,7 @@ export class AttachmentsService extends BaseService { async createMultipleAttachments(data: CreateAttachmentRequest[]) { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Create, Resource.Attachments) - const userId = z.string().parse(this.user.internalUserId) + // TODO: @arpandhakal - $transaction here could consume a lot of sequential db connections, better to use Promise.all // and reuse active connections instead. const newAttachments = await this.db.$transaction(async (prisma) => { @@ -48,7 +49,7 @@ export class AttachmentsService extends BaseService { prisma.attachment.create({ data: { ...attachmentData, - createdById: userId, + createdById: z.string().parse(this.user.internalUserId || this.user.clientId), // CU are also allowed to create attachments workspaceId: this.user.workspaceId, }, }), @@ -86,4 +87,59 @@ export class AttachmentsService extends BaseService { const { data } = await supabase.supabase.storage.from(supabaseBucket).createSignedUrl(filePath, signedUrlTtl) return data?.signedUrl } + + async deleteAttachmentsOfComment(commentId: string) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Delete, Resource.Attachments) + + const commentAttachment = await this.db.$transaction(async (tx) => { + const commentAttachment = await tx.attachment.findMany({ + where: { commentId: commentId, workspaceId: this.user.workspaceId }, + select: { filePath: true }, + }) + + await tx.attachment.deleteMany({ + where: { commentId: commentId, workspaceId: this.user.workspaceId }, + }) + + return commentAttachment + }) + + // directly delete attachments from bucket when deleting comments. + // Postgres transaction is not valid for supabase object so placing it after record deletion from db + const filePathArray = commentAttachment.map((el) => el.filePath) + const supabase = new SupabaseService() + await supabase.removeAttachmentsFromBucket(filePathArray) + } + + async deleteAttachmentsOfTask(taskIds: string[]) { + const taskAttachment = await this.db.$transaction(async (tx) => { + const taskAttachment = await tx.attachment.findMany({ + where: { + taskId: { + in: taskIds, + }, + workspaceId: this.user.workspaceId, + }, + select: { filePath: true }, + }) + + await tx.attachment.deleteMany({ + where: { + taskId: { + in: taskIds, + }, + workspaceId: this.user.workspaceId, + }, + }) + + return taskAttachment + }) + + // directly delete attachments from bucket when deleting comments. + // Postgres transaction is not valid for supabase object so placing it after record deletion from db + const filePathArray = taskAttachment.map((el) => el.filePath) + const supabase = new SupabaseService() + await supabase.removeAttachmentsFromBucket(filePathArray) + } } diff --git a/src/app/api/attachments/public/public.dto.ts b/src/app/api/attachments/public/public.dto.ts new file mode 100644 index 000000000..4b960a717 --- /dev/null +++ b/src/app/api/attachments/public/public.dto.ts @@ -0,0 +1,16 @@ +import { RFC3339DateSchema } from '@/types/common' +import { AssigneeType } from '@prisma/client' +import z from 'zod' + +export const PublicAttachmentDtoSchema = z.object({ + id: z.string().uuid(), + fileName: z.string(), + fileSize: z.number(), + mimeType: z.string(), + downloadUrl: z.string().url().nullable(), + uploadedBy: z.string().uuid(), + uploadedByUserType: z.nativeEnum(AssigneeType).nullable(), + uploadedDate: RFC3339DateSchema, +}) + +export type PublicAttachmentDto = z.infer diff --git a/src/app/api/attachments/public/public.serializer.ts b/src/app/api/attachments/public/public.serializer.ts new file mode 100644 index 000000000..9757673d5 --- /dev/null +++ b/src/app/api/attachments/public/public.serializer.ts @@ -0,0 +1,65 @@ +import { PublicAttachmentDto } from '@/app/api/attachments/public/public.dto' +import { RFC3339DateSchema } from '@/types/common' +import { toRFC3339 } from '@/utils/dateHelper' +import { sanitizeFileName } from '@/utils/sanitizeFileName' +import { createSignedUrls } from '@/utils/signUrl' +import { Attachment, CommentInitiator } from '@prisma/client' +import z from 'zod' + +export class PublicAttachmentSerializer { + /** + * + * @param attachments array of Attachment + * @param uploadedBy id of the one who commented + * @param uploadedByUserType usertype of the one who commented + * @returns Array of PublicAttachmentDto + */ + static async serializeAttachments({ + attachments, + uploadedByUserType, + content, + uploadedBy, + }: { + attachments: Attachment[] + uploadedByUserType: CommentInitiator | null + content: string | null + uploadedBy?: string + }): Promise { + // check if attachments are in the content. If yes + const attachmentPaths = attachments + .map((attachment) => { + return attachment.filePath + }) + .filter((path) => content?.includes(path)) + + const signedUrls = await PublicAttachmentSerializer.getFormattedSignedUrls(attachmentPaths) + + return attachments + .map((attachment) => { + const url = signedUrls.find((item) => item.path === attachment.filePath)?.url + if (!url) return null + return { + id: attachment.id, + fileName: sanitizeFileName(attachment.fileName), + fileSize: attachment.fileSize, + mimeType: attachment.fileType, + downloadUrl: attachment.deletedAt + ? null + : z + .string() + .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) + .parse(url), + uploadedBy: uploadedBy || attachment.createdById, + uploadedByUserType: uploadedByUserType, + uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), + } + }) + .filter((attachment) => attachment !== null) + } + + static async getFormattedSignedUrls(attachmentPaths: string[]) { + if (!attachmentPaths.length) return [] + const signedUrls = await createSignedUrls(attachmentPaths) + return signedUrls.map((item) => ({ path: item.path, url: item.signedUrl })) + } +} diff --git a/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts deleted file mode 100755 index 2e43850a0..000000000 --- a/src/app/api/comment/comment.service.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { sendCommentCreateNotifications } from '@/jobs/notifications' -import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' -import { InitiatedEntity } from '@/types/common' -import { CreateComment, UpdateComment } from '@/types/dto/comment.dto' -import { getArrayDifference, getArrayIntersection } from '@/utils/array' -import { CommentAddedSchema } from '@api/activity-logs/schemas/CommentAddedSchema' -import { ActivityLogger } from '@api/activity-logs/services/activity-logger.service' -import { CommentRepository } from '@api/comment/comment.repository' -import APIError from '@api/core/exceptions/api' -import { BaseService } from '@api/core/services/base.service' -import { PoliciesService } from '@api/core/services/policies.service' -import { Resource } from '@api/core/types/api' -import { UserAction } from '@api/core/types/user' -import { TasksService } from '@api/tasks/tasks.service' -import { ActivityType, Comment, CommentInitiator } from '@prisma/client' -import httpStatus from 'http-status' -import { z } from 'zod' - -export class CommentService extends BaseService { - async create(data: CreateComment) { - const policyGate = new PoliciesService(this.user) - policyGate.authorize(UserAction.Create, Resource.Comment) - - const initiatorId = z.string().parse(this.user.internalUserId || this.user.clientId) - const initiatorType = this.user.internalUserId ? CommentInitiator.internalUser : CommentInitiator.client - - const task = await this.db.task.findFirst({ - where: { - id: data.taskId, - workspaceId: this.user.workspaceId, - }, - }) - if (!task) throw new APIError(httpStatus.NOT_FOUND, `Could not find task with id ${data.taskId}`) - - const comment = await this.db.comment.create({ - data: { - content: data.content, - taskId: data.taskId, - parentId: data.parentId, - workspaceId: this.user.workspaceId, - initiatorId, - // This is safe to do, since if user doesn't have both iu ID / client ID, they will be filtered out way before - initiatorType, - }, - }) - - if (!comment.parentId) { - const activityLogger = new ActivityLogger({ taskId: data.taskId, user: this.user }) - await activityLogger.log( - ActivityType.COMMENT_ADDED, - CommentAddedSchema.parse({ - id: comment.id, - content: comment.content, - initiatorId, - initiatorType, - parentId: comment.parentId, - }), - ) - await sendCommentCreateNotifications.trigger({ user: this.user, task, comment }) - } else { - const tasksService = new TasksService(this.user) - await Promise.all([ - // Update last activity log timestamp for task even on replies so they are reflected in realtime - tasksService.setNewLastActivityLogUpdated(data.taskId), - sendReplyCreateNotifications.trigger({ user: this.user, task, comment }), - ]) - } - - return comment - - // if (data.mentions) { - // await notificationService.createBulkNotification(NotificationTaskActions.Mentioned, task, data.mentions, { - // commentId: comment.id, - // }) - // } - } - - async delete(id: string) { - const policyGate = new PoliciesService(this.user) - policyGate.authorize(UserAction.Delete, Resource.Comment) - - const replyCounts = await this.getReplyCounts([id]) - const comment = await this.db.comment.delete({ where: { id } }) - - // Delete corresponding activity log as well, so as to remove comment from UI - // If activity log exists but comment has a `deletedAt`, show "Comment was deleted" card instead - if (!replyCounts[id]) { - // If there are 0 replies, key won't be in object - await this.deleteRelatedActivityLogs(id) - } - - // If parent comment now has no replies and is also deleted, delete parent as well - if (comment.parentId) { - const parent = await this.db.comment.findFirst({ where: { id: comment.parentId, deletedAt: undefined } }) - if (parent?.deletedAt) { - await this.deleteEmptyParentActivityLog(parent) - } - } - - const tasksService = new TasksService(this.user) - await tasksService.setNewLastActivityLogUpdated(comment.taskId) - return comment - } - - private async deleteEmptyParentActivityLog(parent: Comment) { - const parentReplyCounts = await this.getReplyCounts([parent.id]) - if (!parentReplyCounts[parent.id]) { - await this.deleteRelatedActivityLogs(parent.id) - } - } - - private async deleteRelatedActivityLogs(id: string) { - // Can't use `delete` only here, but only one activity log will have details.id with commentId - await this.db.activityLog.deleteMany({ - where: { - details: { path: ['id'], equals: id }, - }, - }) - } - - async update(id: string, data: UpdateComment) { - const policyGate = new PoliciesService(this.user) - policyGate.authorize(UserAction.Update, Resource.Comment) - - const filters = { id, workspaceId: this.user.workspaceId, initiatorId: this.user.internalUserId, deletedAt: undefined } - const prevComment = await this.db.comment.findFirst({ - where: filters, - }) - if (!prevComment) throw new APIError(httpStatus.NOT_FOUND, 'The comment to update was not found') - - const comment = await this.db.comment.update({ - where: filters, - data, - }) - const tasksService = new TasksService(this.user) - await tasksService.setNewLastActivityLogUpdated(comment.taskId) - return comment - } - - async getCommentById(id: string) { - const comment = await this.db.comment.findFirst({ - where: { id, deletedAt: undefined }, // Can also get soft deleted comments - }) - if (!comment) return null - - let initiator - if (comment?.initiatorType === CommentInitiator.internalUser) { - initiator = await this.copilot.getInternalUser(comment.initiatorId) - } else if (comment?.initiatorType === CommentInitiator.client) { - initiator = await this.copilot.getClient(comment.initiatorId) - } else { - try { - initiator = await this.copilot.getInternalUser(comment.initiatorId) - } catch (e) { - initiator = await this.copilot.getClient(comment.initiatorId) - } - } - - return { ...comment, initiator } - } - - async getCommentsByIds(commentIds: string[]) { - return await this.db.comment.findMany({ - where: { - id: { in: commentIds }, - deletedAt: undefined, // Also get deleted comments (to show if comment parent was deleted) - }, - }) - } - - async getComments({ parentId }: { parentId: string }) { - return await this.db.comment.findMany({ - where: { - parentId, - workspaceId: this.user.workspaceId, - }, - orderBy: { createdAt: 'asc' }, - }) - } - - /** - * Returns an object with parentId as key and array of reply comments containing that comment as parentId - * as value - */ - async getReplyCounts(commentIds: string[]): Promise> { - if (!commentIds) return {} - - const result = await this.db.comment.groupBy({ - by: ['parentId'], - where: { - parentId: { in: commentIds }, - workspaceId: this.user.workspaceId, - deletedAt: null, - }, - _count: { id: true }, - }) - const counts: Record = {} - result.forEach((row) => row.parentId && (counts[row.parentId] = row._count.id)) - return counts - } - - /** - * Gets the first 0 - n number of unique initiators for a comment thread based on the parentIds - */ - async getThreadInitiators( - commentIds: string[], - opts: { - limit?: number - } = { limit: 3 }, - ) { - if (!commentIds.length) return {} - const commentRepo = new CommentRepository(this.user) - const results = await commentRepo.getFirstCommentInitiators(commentIds, opts.limit) - - const initiators: Record = {} - // Extract initiator ids - for (let { parentId, initiatorId, initiatorType } of results) { - if (!parentId) continue - initiators[parentId] ??= [] - initiators[parentId].push(initiatorId) - } - - return initiators - } - - async getReplies(commentIds: string[], expandComments: string[] = []) { - if (!commentIds.length) return [] - - let replies: Comment[] = [] - - // Exclude any expandComments that aren't in commentIds so user can't inject - // random ids to access comments outside of their scope - const validExpandComments = expandComments.length ? getArrayIntersection(commentIds, expandComments) : [] - // Exclude any ids already in expandComments, since this will be used to limit to 3 replies per parent - commentIds = validExpandComments.length ? getArrayDifference(commentIds, validExpandComments) : commentIds - - const commentRepo = new CommentRepository(this.user) - if (validExpandComments.length) { - const expandedReplies = await commentRepo.getAllRepliesForParents(expandComments) - replies = [...replies, ...expandedReplies] - } - const limitedReplies = await commentRepo.getLimitedRepliesForParents(commentIds) - replies = [...replies, ...limitedReplies] - - return replies - } - - async addInitiatorDetails(comments: InitiatedEntity[]) { - if (!comments.length) { - return comments - } - - const [internalUsers, clients] = await Promise.all([this.copilot.getInternalUsers(), this.copilot.getClients()]) - - return comments.map((comment) => { - let initiator - const getUser = (user: { id: string }) => user.id === comment.initiatorId - - if (comment.initiatorType === CommentInitiator.internalUser) { - initiator = internalUsers.data.find(getUser) - } else if (comment.initiatorType === CommentInitiator.client) { - initiator = clients?.data?.find(getUser) - } else { - initiator = internalUsers.data.find(getUser) || clients?.data?.find(getUser) - } - return { ...comment, initiator } - }) - } -} diff --git a/src/app/api/comment/[id]/route.ts b/src/app/api/comments/[id]/route.ts similarity index 67% rename from src/app/api/comment/[id]/route.ts rename to src/app/api/comments/[id]/route.ts index 35f6779aa..f1d261c73 100755 --- a/src/app/api/comment/[id]/route.ts +++ b/src/app/api/comments/[id]/route.ts @@ -1,5 +1,5 @@ import { withErrorHandler } from '@api/core/utils/withErrorHandler' -import { deleteComment, updateComment } from '@api/comment/comment.controller' +import { deleteComment, updateComment } from '@/app/api/comments/comment.controller' export const PATCH = withErrorHandler(updateComment) export const DELETE = withErrorHandler(deleteComment) diff --git a/src/app/api/comment/comment.controller.ts b/src/app/api/comments/comment.controller.ts similarity index 96% rename from src/app/api/comment/comment.controller.ts rename to src/app/api/comments/comment.controller.ts index ea234fe52..ea839d528 100755 --- a/src/app/api/comment/comment.controller.ts +++ b/src/app/api/comments/comment.controller.ts @@ -1,7 +1,7 @@ import { CreateCommentSchema, UpdateCommentSchema } from '@/types/dto/comment.dto' import { getSearchParams } from '@/utils/request' import { signMediaForComments } from '@/utils/signedUrlReplacer' -import { CommentService } from '@api/comment/comment.service' +import { CommentService } from '@/app/api/comments/comment.service' import { IdParams } from '@api/core/types/api' import authenticate from '@api/core/utils/authenticate' import httpStatus from 'http-status' diff --git a/src/app/api/comment/comment.repository.ts b/src/app/api/comments/comment.repository.ts similarity index 100% rename from src/app/api/comment/comment.repository.ts rename to src/app/api/comments/comment.repository.ts diff --git a/src/app/api/comments/comment.service.ts b/src/app/api/comments/comment.service.ts new file mode 100755 index 000000000..d59ce0698 --- /dev/null +++ b/src/app/api/comments/comment.service.ts @@ -0,0 +1,505 @@ +import { AttachmentsService } from '@/app/api/attachments/attachments.service' +import { PublicCommentSerializer } from '@/app/api/comments/public/public.serializer' +import { sendCommentCreateNotifications } from '@/jobs/notifications' +import { sendReplyCreateNotifications } from '@/jobs/notifications/send-reply-create-notifications' +import { InitiatedEntity } from '@/types/common' +import { CreateAttachmentRequestSchema } from '@/types/dto/attachments.dto' +import { CommentsPublicFilterType, CommentWithAttachments, CreateComment, UpdateComment } from '@/types/dto/comment.dto' +import { DISPATCHABLE_EVENT } from '@/types/webhook' +import { getArrayDifference, getArrayIntersection } from '@/utils/array' +import { getFileNameFromPath } from '@/utils/attachmentUtils' +import { getFilePathFromUrl } from '@/utils/signedUrlReplacer' +import { SupabaseActions } from '@/utils/SupabaseActions' +import { getBasicPaginationAttributes } from '@/utils/pagination' +import { CommentAddedSchema } from '@api/activity-logs/schemas/CommentAddedSchema' +import { ActivityLogger } from '@api/activity-logs/services/activity-logger.service' +import { CommentRepository } from '@/app/api/comments/comment.repository' +import APIError from '@api/core/exceptions/api' +import { BaseService } from '@api/core/services/base.service' +import { PoliciesService } from '@api/core/services/policies.service' +import { Resource } from '@api/core/types/api' +import { UserAction } from '@api/core/types/user' +import { TasksService } from '@api/tasks/tasks.service' +import { ActivityType, Comment, CommentInitiator, Prisma, PrismaClient } from '@prisma/client' +import httpStatus from 'http-status' +import { z } from 'zod' +import { getSignedUrl } from '@/utils/signUrl' +import { PublicTasksService } from '@/app/api/tasks/public/public.service' + +export class CommentService extends BaseService { + async create(data: CreateComment) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Create, Resource.Comment) + + const initiatorId = z.string().parse(this.user.internalUserId || this.user.clientId) + const initiatorType = this.user.internalUserId ? CommentInitiator.internalUser : CommentInitiator.client + + const task = await this.db.task.findFirst({ + where: { + id: data.taskId, + workspaceId: this.user.workspaceId, + }, + }) + if (!task) throw new APIError(httpStatus.NOT_FOUND, `Could not find task with id ${data.taskId}`) + + const comment = await this.db.comment.create({ + data: { + content: data.content, + taskId: data.taskId, + parentId: data.parentId, + workspaceId: this.user.workspaceId, + initiatorId, + // This is safe to do, since if user doesn't have both iu ID / client ID, they will be filtered out way before + initiatorType, + }, + include: { attachments: true }, + }) + + let commentToReturn = comment // return the latest comment object with attachments (if any) + try { + if (comment.content) { + const newContent = await this.updateCommentIdOfAttachmentsAfterCreation(comment.content, data.taskId, comment.id) + // mutate commentToReturn here with signed attachment urls + commentToReturn = await this.db.comment.update({ + where: { id: comment.id }, + data: { + content: newContent, + updatedAt: comment.createdAt, //dont updated the updatedAt, because it will show (edited) for recently created comments. + }, + include: { attachments: true }, + }) + console.info('CommentService#createComment | Comment content attachments updated for comment ID:', comment.id) + } + } catch (e: unknown) { + await this.db.comment.delete({ where: { id: comment.id } }) + console.error('CommentService#createComment | Rolling back comment creation', e) + } + + if (!comment.parentId) { + const activityLogger = new ActivityLogger({ taskId: data.taskId, user: this.user }) + await activityLogger.log( + ActivityType.COMMENT_ADDED, + CommentAddedSchema.parse({ + id: comment.id, + content: comment.content, + initiatorId, + initiatorType, + parentId: comment.parentId, + }), + ) + await sendCommentCreateNotifications.trigger({ user: this.user, task, comment }) + } else { + const tasksService = new TasksService(this.user) + await Promise.all([ + // Update last activity log timestamp for task even on replies so they are reflected in realtime + tasksService.setNewLastActivityLogUpdated(data.taskId), + sendReplyCreateNotifications.trigger({ user: this.user, task, comment }), + ]) + } + + // dispatch a webhook event when comment is created + await this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.CommentCreated, { + payload: await PublicCommentSerializer.serialize(commentToReturn), + workspaceId: this.user.workspaceId, + }) + + return commentToReturn + + // if (data.mentions) { + // await notificationService.createBulkNotification(NotificationTaskActions.Mentioned, task, data.mentions, { + // commentId: comment.id, + // }) + // } + } + + async delete(id: string) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Delete, Resource.Comment) + + const commentExists = await this.db.comment.findFirst({ where: { id } }) + if (!commentExists) throw new APIError(httpStatus.NOT_FOUND, 'The comment to delete was not found') + + // delete the comment + const comment = await this.db.comment.delete({ where: { id } }) + + // delete the related attachments as well + const attachmentService = new AttachmentsService(this.user) + await attachmentService.deleteAttachmentsOfComment(comment.id) + + // transaction that deletes the activity logs + return await this.db.$transaction(async (tx) => { + this.setTransaction(tx as PrismaClient) + const replyCounts = await this.getReplyCounts([id]) + + // Delete corresponding activity log as well, so as to remove comment from UI + // If activity log exists but comment has a `deletedAt`, show "Comment was deleted" card instead + if (!replyCounts[id]) { + // If there are 0 replies, key won't be in object + await this.deleteRelatedActivityLogs(id) + } + + // If parent comment now has no replies and is also deleted, delete parent as well + if (comment.parentId) { + const parent = await this.db.comment.findFirst({ where: { id: comment.parentId, deletedAt: undefined } }) + if (parent?.deletedAt) { + await this.deleteEmptyParentActivityLog(parent) + } + } + + const tasksService = new TasksService(this.user) + tasksService.setTransaction(tx as PrismaClient) + + await tasksService.setNewLastActivityLogUpdated(comment.taskId) + tasksService.unsetTransaction() + + this.unsetTransaction() + return { ...comment, attachments: [] } // send empty attachments array + }) + } + + private async deleteEmptyParentActivityLog(parent: Comment) { + const parentReplyCounts = await this.getReplyCounts([parent.id]) + if (!parentReplyCounts[parent.id]) { + await this.deleteRelatedActivityLogs(parent.id) + } + } + + private async deleteRelatedActivityLogs(id: string) { + // Can't use `delete` only here, but only one activity log will have details.id with commentId + await this.db.activityLog.deleteMany({ + where: { + details: { path: ['id'], equals: id }, + }, + }) + } + + async update(id: string, data: UpdateComment) { + const policyGate = new PoliciesService(this.user) + policyGate.authorize(UserAction.Update, Resource.Comment) + + const filters = { id, workspaceId: this.user.workspaceId, initiatorId: this.user.internalUserId, deletedAt: undefined } + const prevComment = await this.db.comment.findFirst({ + where: filters, + }) + if (!prevComment) throw new APIError(httpStatus.NOT_FOUND, 'The comment to update was not found') + + const comment = await this.db.comment.update({ + where: filters, + data, + }) + const tasksService = new TasksService(this.user) + await tasksService.setNewLastActivityLogUpdated(comment.taskId) + return comment + } + + async getCommentById({ id, includeAttachments }: { id: string; includeAttachments?: boolean }) { + const comment = await this.db.comment.findFirst({ + where: { id, deletedAt: undefined }, // Can also get soft deleted comments + include: { attachments: includeAttachments }, + }) + if (!comment) throw new APIError(httpStatus.NOT_FOUND, 'The requested comment was not found') + + let initiator + if (comment?.initiatorType === CommentInitiator.internalUser) { + initiator = await this.copilot.getInternalUser(comment.initiatorId) + } else if (comment?.initiatorType === CommentInitiator.client) { + initiator = await this.copilot.getClient(comment.initiatorId) + } else { + try { + initiator = await this.copilot.getInternalUser(comment.initiatorId) + } catch (e) { + initiator = await this.copilot.getClient(comment.initiatorId) + } + } + + return { ...comment, initiator } + } + + async getCommentsByIds(commentIds: string[]) { + return await this.db.comment.findMany({ + where: { + id: { in: commentIds }, + deletedAt: undefined, // Also get deleted comments (to show if comment parent was deleted) + }, + }) + } + + async getComments({ parentId }: { parentId: string }) { + return await this.db.comment.findMany({ + where: { + parentId, + workspaceId: this.user.workspaceId, + }, + orderBy: { createdAt: 'asc' }, + }) + } + + /** + * Returns an object with parentId as key and array of reply comments containing that comment as parentId + * as value + */ + async getReplyCounts(commentIds: string[]): Promise> { + if (!commentIds) return {} + + const result = await this.db.comment.groupBy({ + by: ['parentId'], + where: { + parentId: { in: commentIds }, + workspaceId: this.user.workspaceId, + deletedAt: null, + }, + _count: { id: true }, + }) + const counts: Record = {} + result.forEach((row) => row.parentId && (counts[row.parentId] = row._count.id)) + return counts + } + + /** + * Gets the first 0 - n number of unique initiators for a comment thread based on the parentIds + */ + async getThreadInitiators( + commentIds: string[], + opts: { + limit?: number + } = { limit: 3 }, + ) { + if (!commentIds.length) return {} + const commentRepo = new CommentRepository(this.user) + const results = await commentRepo.getFirstCommentInitiators(commentIds, opts.limit) + + const initiators: Record = {} + // Extract initiator ids + for (let { parentId, initiatorId, initiatorType } of results) { + if (!parentId) continue + initiators[parentId] ??= [] + initiators[parentId].push(initiatorId) + } + + return initiators + } + + async getReplies(commentIds: string[], expandComments: string[] = []) { + if (!commentIds.length) return [] + + let replies: Comment[] = [] + + // Exclude any expandComments that aren't in commentIds so user can't inject + // random ids to access comments outside of their scope + const validExpandComments = expandComments.length ? getArrayIntersection(commentIds, expandComments) : [] + // Exclude any ids already in expandComments, since this will be used to limit to 3 replies per parent + commentIds = validExpandComments.length ? getArrayDifference(commentIds, validExpandComments) : commentIds + + const commentRepo = new CommentRepository(this.user) + if (validExpandComments.length) { + const expandedReplies = await commentRepo.getAllRepliesForParents(expandComments) + replies = [...replies, ...expandedReplies] + } + const limitedReplies = await commentRepo.getLimitedRepliesForParents(commentIds) + replies = [...replies, ...limitedReplies] + + return replies + } + + async addInitiatorDetails(comments: InitiatedEntity[]) { + if (!comments.length) { + return comments + } + + const [internalUsers, clients] = await Promise.all([this.copilot.getInternalUsers(), this.copilot.getClients()]) + + return comments.map((comment) => { + let initiator + const getUser = (user: { id: string }) => user.id === comment.initiatorId + + if (comment.initiatorType === CommentInitiator.internalUser) { + initiator = internalUsers.data.find(getUser) + } else if (comment.initiatorType === CommentInitiator.client) { + initiator = clients?.data?.find(getUser) + } else { + initiator = internalUsers.data.find(getUser) || clients?.data?.find(getUser) + } + return { ...comment, initiator } + }) + } + + private async updateCommentIdOfAttachmentsAfterCreation(htmlString: string, task_id: string, commentId: string) { + const imgTagRegex = /]*src="([^"]+)"[^>]*>/g //expression used to match all img srcs in provided HTML string. + const attachmentTagRegex = /<\s*[a-zA-Z]+\s+[^>]*data-type="attachment"[^>]*src="([^"]+)"[^>]*>/g //expression used to match all attachment srcs in provided HTML string. + let match + const replacements: { originalSrc: string; newUrl: string }[] = [] + + const newFilePaths: { originalSrc: string; newFilePath: string }[] = [] + const copyAttachmentPromises: Promise[] = [] + const createAttachmentPayloads = [] + const matches: { originalSrc: string; filePath: string; fileName: string }[] = [] + + while ((match = imgTagRegex.exec(htmlString)) !== null) { + const originalSrc = match[1] + const filePath = getFilePathFromUrl(originalSrc) + const fileName = filePath?.split('/').pop() + if (filePath && fileName) { + matches.push({ originalSrc, filePath, fileName }) + } + } + + while ((match = attachmentTagRegex.exec(htmlString)) !== null) { + const originalSrc = match[1] + const filePath = getFilePathFromUrl(originalSrc) + const fileName = filePath?.split('/').pop() + if (filePath && fileName) { + matches.push({ originalSrc, filePath, fileName }) + } + } + + for (const { originalSrc, filePath, fileName } of matches) { + const newFilePath = `${this.user.workspaceId}/${task_id}/comments/${commentId}/${fileName}` + const supabaseActions = new SupabaseActions() + + const fileMetaData = await supabaseActions.getMetaData(filePath) + createAttachmentPayloads.push( + CreateAttachmentRequestSchema.parse({ + commentId: commentId, + filePath: newFilePath, + fileSize: fileMetaData?.size, + fileType: fileMetaData?.contentType, + fileName: fileMetaData?.metadata?.originalFileName || getFileNameFromPath(newFilePath), + }), + ) + copyAttachmentPromises.push(supabaseActions.moveAttachment(filePath, newFilePath)) + newFilePaths.push({ originalSrc, newFilePath }) + } + + await Promise.all(copyAttachmentPromises) + const attachmentService = new AttachmentsService(this.user) + if (createAttachmentPayloads.length) { + await attachmentService.createMultipleAttachments(createAttachmentPayloads) + } + + const signedUrlPromises = newFilePaths.map(async ({ originalSrc, newFilePath }) => { + const newUrl = await getSignedUrl(newFilePath) + if (newUrl) { + replacements.push({ originalSrc, newUrl }) + } + }) + + await Promise.all(signedUrlPromises) + + for (const { originalSrc, newUrl } of replacements) { + htmlString = htmlString.replace(originalSrc, newUrl) + } + // const filePaths = newFilePaths.map(({ newFilePath }) => newFilePath) + // await this.db.scrapMedia.updateMany({ + // where: { + // filePath: { + // in: filePaths, + // }, + // }, + // data: { + // taskId: task_id, + // }, + // }) //todo: add support for commentId in scrapMedias. + return htmlString + } //todo: make this resuable since this is highly similar to what we are doing on tasks. + + async getAllComments(queryFilters: CommentsPublicFilterType): Promise { + const { parentId, taskId, limit, lastIdCursor, initiatorId } = queryFilters + const where: Prisma.CommentWhereInput = { + parentId, + taskId, + initiatorId, + workspaceId: this.user.workspaceId, + } + + const pagination = getBasicPaginationAttributes(limit, lastIdCursor) + if (this.user.clientId || this.user.companyId) { + where.task = this.getClientOrCompanyAssigneeFilter() + } + + return await this.db.comment.findMany({ + where, + ...pagination, + include: { attachments: true }, + orderBy: { createdAt: 'desc' }, + }) + } + + async hasMoreCommentsAfterCursor(id: string, publicFilters: Partial): Promise { + const where: Prisma.CommentWhereInput = { + ...publicFilters, + workspaceId: this.user.workspaceId, + } + if (this.user.clientId || this.user.companyId) { + where.task = this.getClientOrCompanyAssigneeFilter() + } + const newComment = await this.db.comment.findFirst({ + where, + cursor: { id }, + skip: 1, + orderBy: { createdAt: 'desc' }, + }) + return !!newComment + } + + /** + * If the user has permission to access the task, it means the user has access to the task's comments + * Therefore checking the task permission + */ + async checkCommentTaskPermissionForUser(taskId: string) { + try { + const publicTask = new PublicTasksService(this.user) + await publicTask.getOneTask(taskId) + } catch (err: unknown) { + if (err instanceof APIError) { + let status: number = httpStatus.UNAUTHORIZED, + message = 'You are not authorized to perform this action' + if (err.status === httpStatus.NOT_FOUND) { + status = httpStatus.NOT_FOUND + message = 'A task for the requested comment was not found' + } + throw new APIError(status, message) + } + throw err + } + } + + protected getClientOrCompanyAssigneeFilter(includeViewer: boolean = true): Prisma.TaskWhereInput { + const clientId = z.string().uuid().parse(this.user.clientId) + const companyId = z.string().uuid().parse(this.user.companyId) + + const filters = [] + + if (clientId && companyId) { + filters.push( + // Get client tasks for the particular companyId + { clientId, companyId }, + // Get company tasks for the client's companyId + { companyId, clientId: null }, + ) + if (includeViewer) + filters.push( + // Get tasks that includes the client as a viewer + { + viewers: { + hasSome: [{ clientId, companyId }, { companyId }], + }, + }, + ) + } else if (companyId) { + filters.push( + // Get only company tasks for the client's companyId + { clientId: null, companyId }, + ) + if (includeViewer) + filters.push( + // Get tasks that includes the company as a viewer + { + viewers: { + hasSome: [{ companyId }], + }, + }, + ) + } + return filters.length > 0 ? { OR: filters } : {} + } //Repeated twice because taskSharedService is an abstract class. +} diff --git a/src/app/api/comments/public/[id]/route.ts b/src/app/api/comments/public/[id]/route.ts new file mode 100644 index 000000000..b9864d29b --- /dev/null +++ b/src/app/api/comments/public/[id]/route.ts @@ -0,0 +1,5 @@ +import { deleteOneCommentPublic, getOneCommentPublic } from '@/app/api/comments/public/public.controller' +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' + +export const GET = withErrorHandler(getOneCommentPublic) +export const DELETE = withErrorHandler(deleteOneCommentPublic) diff --git a/src/app/api/comments/public/public.controller.ts b/src/app/api/comments/public/public.controller.ts new file mode 100644 index 000000000..b0766c09f --- /dev/null +++ b/src/app/api/comments/public/public.controller.ts @@ -0,0 +1,78 @@ +import { CommentService } from '@/app/api/comments/comment.service' +import { PublicCommentSerializer } from '@/app/api/comments/public/public.serializer' +import authenticate from '@/app/api/core/utils/authenticate' +import { CommentsPublicFilterType } from '@/types/dto/comment.dto' +import { getPaginationLimit } from '@/utils/pagination' +import { getSearchParams } from '@/utils/request' +import { decode, encode } from 'js-base64' +import { NextRequest, NextResponse } from 'next/server' + +type TaskAndCommentIdParams = { + params: Promise<{ id: string }> +} + +export const getAllCommentsPublic = async (req: NextRequest) => { + const user = await authenticate(req) + + const { parentCommentId, createdBy, limit, nextToken, taskId } = getSearchParams(req.nextUrl.searchParams, [ + 'parentCommentId', + 'createdBy', + 'limit', + 'nextToken', + 'taskId', + ]) + + const publicFilters: CommentsPublicFilterType = { + taskId: taskId || undefined, + parentId: parentCommentId || undefined, + initiatorId: createdBy || undefined, + } + + const commentService = new CommentService(user) + if (taskId) { + await commentService.checkCommentTaskPermissionForUser(taskId) // check the user accessing the comment has access to the task + } + + const comments = await commentService.getAllComments({ + limit: getPaginationLimit(limit), + lastIdCursor: nextToken ? decode(nextToken) : undefined, + ...publicFilters, + }) + + const lastCommentId = comments[comments.length - 1]?.id + const hasMoreComments = lastCommentId + ? await commentService.hasMoreCommentsAfterCursor(lastCommentId, publicFilters) + : false + const base64NextToken = hasMoreComments ? encode(lastCommentId) : undefined + + return NextResponse.json({ + data: await PublicCommentSerializer.serializeMany(comments), + nextToken: base64NextToken, + }) +} + +export const getOneCommentPublic = async (req: NextRequest, { params }: TaskAndCommentIdParams) => { + const { id } = await params + const user = await authenticate(req) + + const commentService = new CommentService(user) + const comment = await commentService.getCommentById({ id, includeAttachments: true }) + if (!comment) return NextResponse.json({ data: null }) + + await commentService.checkCommentTaskPermissionForUser(comment.taskId) // check the user accessing the comment has access to the task + + return NextResponse.json({ data: await PublicCommentSerializer.serialize(comment) }) +} + +export const deleteOneCommentPublic = async (req: NextRequest, { params }: TaskAndCommentIdParams) => { + const { id } = await params + const user = await authenticate(req) + + const commentService = new CommentService(user) + + const deletedComment = await commentService.delete(id) + + await commentService.checkCommentTaskPermissionForUser(deletedComment.taskId) // check the user accessing the comment has access to the task + + return NextResponse.json({ ...(await PublicCommentSerializer.serialize(deletedComment)) }) +} diff --git a/src/app/api/comments/public/public.dto.ts b/src/app/api/comments/public/public.dto.ts new file mode 100644 index 000000000..f1ed9495f --- /dev/null +++ b/src/app/api/comments/public/public.dto.ts @@ -0,0 +1,19 @@ +import { PublicAttachmentDtoSchema } from '@/app/api/attachments/public/public.dto' +import { RFC3339DateSchema } from '@/types/common' +import { AssigneeType } from '@prisma/client' +import z from 'zod' + +export const PublicCommentDtoSchema = z.object({ + id: z.string().uuid(), + object: z.literal('taskComment'), + taskId: z.string().uuid(), + parentCommentId: z.string().uuid().nullable(), + content: z.string(), + createdBy: z.string().uuid(), + createdByUserType: z.nativeEnum(AssigneeType).nullable(), + createdDate: RFC3339DateSchema, + updatedDate: RFC3339DateSchema, + deletedDate: RFC3339DateSchema.nullable(), + attachments: z.array(PublicAttachmentDtoSchema).nullable(), +}) +export type PublicCommentDto = z.infer diff --git a/src/app/api/comments/public/public.serializer.ts b/src/app/api/comments/public/public.serializer.ts new file mode 100644 index 000000000..d0af7e223 --- /dev/null +++ b/src/app/api/comments/public/public.serializer.ts @@ -0,0 +1,39 @@ +import { PublicAttachmentSerializer } from '@/app/api/attachments/public/public.serializer' +import { PublicCommentDto, PublicCommentDtoSchema } from '@/app/api/comments/public/public.dto' +import { RFC3339DateSchema } from '@/types/common' +import { CommentWithAttachments } from '@/types/dto/comment.dto' +import { toRFC3339 } from '@/utils/dateHelper' +import { sanitizeHtml } from '@/utils/santizeContents' +import { z } from 'zod' + +export class PublicCommentSerializer { + static async serializeUnsafe(comment: CommentWithAttachments): Promise { + return { + id: comment.id, + object: 'taskComment', + parentCommentId: comment.parentId, + taskId: comment.taskId, + content: sanitizeHtml(comment.content), + createdBy: comment.initiatorId, + createdByUserType: comment.initiatorType, + createdDate: RFC3339DateSchema.parse(toRFC3339(comment.createdAt)), + updatedDate: RFC3339DateSchema.parse(toRFC3339(comment.updatedAt)), + deletedDate: toRFC3339(comment.deletedAt), + attachments: await PublicAttachmentSerializer.serializeAttachments({ + attachments: comment.attachments, + uploadedByUserType: comment.initiatorType, + uploadedBy: comment.initiatorId, + content: comment.content, + }), + } + } + + static async serialize(comment: CommentWithAttachments): Promise { + return PublicCommentDtoSchema.parse(await PublicCommentSerializer.serializeUnsafe(comment)) + } + + static async serializeMany(comments: CommentWithAttachments[]): Promise { + const serializedComments = await Promise.all(comments.map(async (comment) => PublicCommentSerializer.serialize(comment))) + return z.array(PublicCommentDtoSchema).parse(serializedComments) + } +} diff --git a/src/app/api/comments/public/route.ts b/src/app/api/comments/public/route.ts new file mode 100644 index 000000000..49bdff2b0 --- /dev/null +++ b/src/app/api/comments/public/route.ts @@ -0,0 +1,4 @@ +import { getAllCommentsPublic } from '@/app/api/comments/public/public.controller' +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' + +export const GET = withErrorHandler(getAllCommentsPublic) diff --git a/src/app/api/comment/route.ts b/src/app/api/comments/route.ts similarity index 91% rename from src/app/api/comment/route.ts rename to src/app/api/comments/route.ts index de318a163..d4d424480 100755 --- a/src/app/api/comment/route.ts +++ b/src/app/api/comments/route.ts @@ -1,5 +1,5 @@ import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' -import { createComment, getFilteredComments } from '@/app/api/comment/comment.controller' +import { createComment, getFilteredComments } from '@/app/api/comments/comment.controller' export const maxDuration = 300 diff --git a/src/app/api/core/services/supabase.service.ts b/src/app/api/core/services/supabase.service.ts index 3cd49967f..f41031668 100644 --- a/src/app/api/core/services/supabase.service.ts +++ b/src/app/api/core/services/supabase.service.ts @@ -1,9 +1,22 @@ +import APIError from '@/app/api/core/exceptions/api' +import { supabaseBucket } from '@/config' import SupabaseClient from '@/lib/supabase' import { type SupabaseClient as SupabaseJSClient } from '@supabase/supabase-js' +import httpStatus from 'http-status' /** * Base Service with access to supabase client */ export class SupabaseService { public supabase: SupabaseJSClient = SupabaseClient.getInstance() + + async removeAttachmentsFromBucket(attachmentsToDelete: string[]) { + if (attachmentsToDelete.length !== 0) { + const { error } = await this.supabase.storage.from(supabaseBucket).remove(attachmentsToDelete) + if (error) { + console.error(error) + throw new APIError(httpStatus.NOT_FOUND, 'unable to delete some date from supabase') + } + } + } } diff --git a/src/app/api/notification/validate-count/route.ts b/src/app/api/notification/validate-count/route.ts index 41b3cc501..791dbed65 100644 --- a/src/app/api/notification/validate-count/route.ts +++ b/src/app/api/notification/validate-count/route.ts @@ -1,4 +1,6 @@ import { withErrorHandler } from '@api/core/utils/withErrorHandler' import { validateCount } from '@api/notification/validate-count/validateCount.controller' +export const maxDuration = 300 + export const GET = withErrorHandler(validateCount) diff --git a/src/app/api/tasks/public/public.controller.ts b/src/app/api/tasks/public/public.controller.ts index 0638e7fde..0e7ade4a1 100644 --- a/src/app/api/tasks/public/public.controller.ts +++ b/src/app/api/tasks/public/public.controller.ts @@ -47,7 +47,7 @@ export const getAllTasksPublic = async (req: NextRequest) => { const base64NextToken = hasMoreTasks ? encode(lastTaskId) : undefined return NextResponse.json({ - data: PublicTaskSerializer.serializeMany(tasks), + data: await PublicTaskSerializer.serializeMany(tasks), nextToken: base64NextToken, }) } @@ -57,7 +57,7 @@ export const getOneTaskPublic = async (req: NextRequest, { params }: IdParams) = const user = await authenticate(req) const tasksService = new PublicTasksService(user) const task = await tasksService.getOneTask(id) - return NextResponse.json(PublicTaskSerializer.serialize(task)) + return NextResponse.json(await PublicTaskSerializer.serialize(task)) } export const createTaskPublic = async (req: NextRequest) => { @@ -73,7 +73,7 @@ export const createTaskPublic = async (req: NextRequest) => { const newTask = await tasksService.createTask(createPayload) console.info('Created new public task:', newTask) - return NextResponse.json(PublicTaskSerializer.serialize(newTask)) + return NextResponse.json(await PublicTaskSerializer.serialize(newTask)) } export const updateTaskPublic = async (req: NextRequest, { params }: IdParams) => { @@ -85,7 +85,7 @@ export const updateTaskPublic = async (req: NextRequest, { params }: IdParams) = const updatePayload = await PublicTaskSerializer.deserializeUpdatePayload(data, user.workspaceId) const updatedTask = await tasksService.updateTask(id, updatePayload) - return NextResponse.json(PublicTaskSerializer.serialize(updatedTask)) + return NextResponse.json(await PublicTaskSerializer.serialize(updatedTask)) } export const deleteOneTaskPublic = async (req: NextRequest, { params }: IdParams) => { @@ -94,5 +94,5 @@ export const deleteOneTaskPublic = async (req: NextRequest, { params }: IdParams const user = await authenticate(req) const tasksService = new PublicTasksService(user) const task = await tasksService.deleteTask(id, z.coerce.boolean().parse(recursive)) - return NextResponse.json({ ...PublicTaskSerializer.serialize(task) }) + return NextResponse.json({ ...(await PublicTaskSerializer.serialize(task)) }) } diff --git a/src/app/api/tasks/public/public.dto.ts b/src/app/api/tasks/public/public.dto.ts index bd0d5081a..e909f25a8 100644 --- a/src/app/api/tasks/public/public.dto.ts +++ b/src/app/api/tasks/public/public.dto.ts @@ -3,6 +3,7 @@ import { CopilotAPI } from '@/utils/CopilotAPI' import { AssigneeType } from '@prisma/client' import { z } from 'zod' import { validateUserIds, ViewersSchema } from '@/types/dto/tasks.dto' +import { PublicAttachmentDtoSchema } from '@/app/api/attachments/public/public.dto' export const TaskSourceSchema = z.enum(['web', 'api']) export type TaskSource = z.infer @@ -41,6 +42,7 @@ export const PublicTaskDtoSchema = z.object({ clientId: z.string().uuid().nullable(), companyId: z.string().uuid().nullable(), viewers: ViewersSchema, + attachments: z.array(PublicAttachmentDtoSchema), }) export type PublicTaskDto = z.infer diff --git a/src/app/api/tasks/public/public.serializer.ts b/src/app/api/tasks/public/public.serializer.ts index 6b727aba2..1279802e7 100644 --- a/src/app/api/tasks/public/public.serializer.ts +++ b/src/app/api/tasks/public/public.serializer.ts @@ -1,3 +1,4 @@ +import { PublicAttachmentSerializer } from '@/app/api/attachments/public/public.serializer' import APIError from '@/app/api/core/exceptions/api' import DBClient from '@/lib/db' import { RFC3339DateSchema } from '@/types/common' @@ -9,11 +10,12 @@ import { ViewersSchema, } from '@/types/dto/tasks.dto' import { rfc3339ToDateString, toRFC3339 } from '@/utils/dateHelper' +import { sanitizeHtml } from '@/utils/santizeContents' import { copyTemplateMediaToTask } from '@/utils/signedTemplateUrlReplacer' import { replaceImageSrc } from '@/utils/signedUrlReplacer' import { getSignedUrl } from '@/utils/signUrl' import { PublicTaskCreateDto, PublicTaskDto, PublicTaskDtoSchema, PublicTaskUpdateDto } from '@api/tasks/public/public.dto' -import { Task, WorkflowState } from '@prisma/client' +import { Attachment, Task, WorkflowState } from '@prisma/client' import httpStatus from 'http-status' import { z } from 'zod' @@ -31,13 +33,15 @@ export const workflowStateTypeMap: Record { return { id: task.id, object: 'task', name: task.title, - description: task.body || '', + description: sanitizeHtml(task.body || ''), parentTaskId: task.parentId, dueDate: toRFC3339(task.dueDate), label: task.label, @@ -60,15 +64,21 @@ export class PublicTaskSerializer { clientId: task.clientId, companyId: task.companyId, viewers: ViewersSchema.parse(task.viewers), + attachments: await PublicAttachmentSerializer.serializeAttachments({ + attachments: task.attachments, + uploadedByUserType: 'internalUser', // task creator is always IU + content: task.body, + }), } } - static serialize(task: Task & { workflowState: WorkflowState }): PublicTaskDto { - return PublicTaskDtoSchema.parse(PublicTaskSerializer.serializeUnsafe(task)) + static async serialize(task: TaskWithWorkflowStateAndAttachments): Promise { + return PublicTaskDtoSchema.parse(await PublicTaskSerializer.serializeUnsafe(task)) } - static serializeMany(tasks: (Task & { workflowState: WorkflowState })[]): PublicTaskDto[] { - return z.array(PublicTaskDtoSchema).parse(tasks.map((task) => PublicTaskSerializer.serializeUnsafe(task))) + static async serializeMany(tasks: TaskWithWorkflowStateAndAttachments[]): Promise { + const serializedTasks = await Promise.all(tasks.map(async (task) => PublicTaskSerializer.serializeUnsafe(task))) + return z.array(PublicTaskDtoSchema).parse(serializedTasks) } static async getWorkflowStateIdForStatus( diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index f4f3745f7..1edbf3835 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -23,7 +23,9 @@ import { LabelMappingService } from '@api/label-mapping/label-mapping.service' import { SubtaskService } from '@api/tasks/subtasks.service' import { TasksActivityLogger } from '@api/tasks/tasks.logger' import { TemplatesService } from '@api/tasks/templates/templates.service' -import { PublicTaskSerializer } from '@api/tasks/public/public.serializer' +import { PublicTaskSerializer, TaskWithWorkflowStateAndAttachments } from '@api/tasks/public/public.serializer' +import { getBasicPaginationAttributes } from '@/utils/pagination' +import { AttachmentsService } from '@/app/api/attachments/attachments.service' export class PublicTasksService extends TasksSharedService { async getAllTasks(queryFilters: { @@ -39,7 +41,7 @@ export class PublicTasksService extends TasksSharedService { workflowState?: { type: StateType | { not: StateType } } limit?: number lastIdCursor?: string - }): Promise { + }): Promise { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Read, Resource.Tasks) @@ -80,24 +82,23 @@ export class PublicTasksService extends TasksSharedService { } const orderBy: Prisma.TaskOrderByWithRelationInput[] = [{ createdAt: 'desc' }] - const pagination: Prisma.TaskFindManyArgs = { - take: queryFilters.limit, - cursor: queryFilters.lastIdCursor ? { id: queryFilters.lastIdCursor } : undefined, - skip: queryFilters.lastIdCursor ? 1 : undefined, - } + const pagination = getBasicPaginationAttributes(queryFilters.limit, queryFilters.lastIdCursor) const tasks = await this.db.task.findMany({ where, orderBy, ...pagination, relationLoadStrategy: 'join', - include: { workflowState: true }, + include: { + workflowState: true, + attachments: true, + }, }) return tasks } - async getOneTask(id: string): Promise { + async getOneTask(id: string): Promise { const policyGate = new PoliciesService(this.user) policyGate.authorize(UserAction.Read, Resource.Tasks) @@ -105,7 +106,15 @@ export class PublicTasksService extends TasksSharedService { // while clients can only view the tasks assigned to them or their company const filters = this.buildTaskPermissions(id) const where = { ...filters, deletedAt: { not: undefined } } - const task = await this.db.task.findFirst({ where, relationLoadStrategy: 'join', include: { workflowState: true } }) + const task = await this.db.task.findFirst({ + where, + relationLoadStrategy: 'join', + include: { + workflowState: true, + attachments: true, + }, + }) + if (!task) throw new APIError(httpStatus.NOT_FOUND, 'The requested task was not found') if (this.user.internalUserId) { await this.checkClientAccessForTask(task, this.user.internalUserId) @@ -187,7 +196,10 @@ export class PublicTasksService extends TasksSharedService { ...(opts?.manualTimestamp && { createdAt: opts.manualTimestamp }), ...(await getTaskTimestamps('create', this.user, data, undefined, workflowStateStatus)), }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: true, + }, }) console.info('PublicTasksService#createTask | Task created with ID:', newTask.id) @@ -240,7 +252,7 @@ export class PublicTasksService extends TasksSharedService { await Promise.all([ sendTaskCreateNotifications.trigger({ user: this.user, task: newTask }), this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskCreated, { - payload: PublicTaskSerializer.serialize(newTask), + payload: await PublicTaskSerializer.serialize(newTask), workspaceId: this.user.workspaceId, }), ]) @@ -363,7 +375,10 @@ export class PublicTasksService extends TasksSharedService { ...userAssignmentFields, ...(await getTaskTimestamps('update', this.user, data, prevTask)), }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: true, + }, }) subtaskService.setTransaction(tx as PrismaClient) // Archive / unarchive all subtasks if parent task is archived / unarchived @@ -412,6 +427,7 @@ export class PublicTasksService extends TasksSharedService { //delete the associated label const labelMappingService = new LabelMappingService(this.user) + // Note: this transaction is timing out in local machine const updatedTask = await this.db.$transaction(async (tx) => { labelMappingService.setTransaction(tx as PrismaClient) await labelMappingService.deleteLabel(task?.label) @@ -429,13 +445,14 @@ export class PublicTasksService extends TasksSharedService { await subtaskService.decreaseSubtaskCount(task.parentId) } await subtaskService.softDeleteAllSubtasks(task.id) - return deletedTask + return { ...deletedTask, attachments: [] } // empty attachments array for deleted tasks }) await Promise.all([ + new AttachmentsService(this.user).deleteAttachmentsOfTask([task.id]), // delete attachments of the task and its subtasks deleteTaskNotifications.trigger({ user: this.user, task }), this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskDeleted, { - payload: PublicTaskSerializer.serialize(updatedTask), + payload: await PublicTaskSerializer.serialize(updatedTask), workspaceId: this.user.workspaceId, }), ]) diff --git a/src/app/api/tasks/tasks.helpers.ts b/src/app/api/tasks/tasks.helpers.ts index 505d10569..80f113215 100644 --- a/src/app/api/tasks/tasks.helpers.ts +++ b/src/app/api/tasks/tasks.helpers.ts @@ -6,7 +6,7 @@ import { DISPATCHABLE_EVENT } from '@/types/webhook' import { CopilotAPI } from '@/utils/CopilotAPI' import User from '@api/core/models/User.model' import { TaskTimestamps } from '@api/core/types/tasks' -import { PublicTaskSerializer } from '@api/tasks/public/public.serializer' +import { PublicTaskSerializer, TaskWithWorkflowStateAndAttachments } from '@api/tasks/public/public.serializer' import WorkflowStatesService from '@api/workflow-states/workflowStates.service' import { LogStatus, StateType, Task, WorkflowState } from '@prisma/client' @@ -89,7 +89,7 @@ export const getTaskTimestamps = async ( export const dispatchUpdatedWebhookEvent = async ( user: User, prevTask: Task, - updatedTask: TaskWithWorkflowState, + updatedTask: TaskWithWorkflowStateAndAttachments, isPublicApi: boolean, ): Promise => { let event: DISPATCHABLE_EVENT | undefined @@ -119,7 +119,7 @@ export const dispatchUpdatedWebhookEvent = async ( if (event) { await copilot.dispatchWebhook(event, { workspaceId: user.workspaceId, - payload: PublicTaskSerializer.serialize(updatedTask), + payload: await PublicTaskSerializer.serialize(updatedTask), }) } } diff --git a/src/app/api/tasks/tasks.service.ts b/src/app/api/tasks/tasks.service.ts index 4c1eab127..af8b9a405 100644 --- a/src/app/api/tasks/tasks.service.ts +++ b/src/app/api/tasks/tasks.service.ts @@ -185,7 +185,10 @@ export class TasksService extends TasksSharedService { ...(opts?.manualTimestamp && { createdAt: opts.manualTimestamp }), ...(await getTaskTimestamps('create', this.user, data, undefined, workflowStateStatus)), }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: true, + }, }) console.info('TasksService#createTask | Task created with ID:', newTask.id) @@ -238,7 +241,7 @@ export class TasksService extends TasksSharedService { await Promise.all([ sendTaskCreateNotifications.trigger({ user: this.user, task: newTask }), this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskCreated, { - payload: PublicTaskSerializer.serialize(newTask), + payload: await PublicTaskSerializer.serialize(newTask), workspaceId: this.user.workspaceId, }), ]) @@ -402,7 +405,10 @@ export class TasksService extends TasksSharedService { ...userAssignmentFields, ...(await getTaskTimestamps('update', this.user, data, prevTask)), }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: true, + }, }) subtaskService.setTransaction(tx as PrismaClient) // Archive / unarchive all subtasks if parent task is archived / unarchived @@ -457,7 +463,10 @@ export class TasksService extends TasksSharedService { const deletedTask = await tx.task.update({ where: { id, workspaceId: this.user.workspaceId }, relationLoadStrategy: 'join', - include: { workflowState: true }, + include: { + workflowState: true, + attachments: true, + }, data: { deletedAt: new Date(), deletedBy: deletedBy }, }) await this.setNewLastSubtaskUpdated(task.parentId) //updates lastSubtaskUpdated timestamp of parent task if there is task.parentId @@ -473,7 +482,7 @@ export class TasksService extends TasksSharedService { await Promise.all([ deleteTaskNotifications.trigger({ user: this.user, task }), this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskDeleted, { - payload: PublicTaskSerializer.serialize(updatedTask), + payload: await PublicTaskSerializer.serialize(updatedTask), workspaceId: this.user.workspaceId, }), ]) @@ -602,7 +611,10 @@ export class TasksService extends TasksSharedService { completedByUserType, ...(await getTaskTimestamps('update', this.user, data, prevTask)), }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: true, + }, }) if (updatedTask) { diff --git a/src/app/api/tasks/tasksShared.service.ts b/src/app/api/tasks/tasksShared.service.ts index d5ca9abf8..fb96d6dcb 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: fileMetaData?.metadata?.originalFileName || 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..ca637f7e8 100644 --- a/src/app/api/workers/scrap-medias/scrap-medias.service.ts +++ b/src/app/api/workers/scrap-medias/scrap-medias.service.ts @@ -77,13 +77,13 @@ export class ScrapMediaService { console.error('Error processing scrap image', e) } } - if (scrapMediasToDeleteFromBucket.length !== 0) { - const { error } = await supabase.supabase.storage.from(supabaseBucket).remove(scrapMediasToDeleteFromBucket) - if (error) { - console.error(error) - throw new APIError(404, 'unable to delete some date from supabase') - } - } + + if (!!scrapMediasToDeleteFromBucket.length) + await db.attachment.deleteMany({ where: { filePath: { in: scrapMediasToDeleteFromBucket } } }) + + // remove attachments from bucket + await supabase.removeAttachmentsFromBucket(scrapMediasToDeleteFromBucket) + if (scrapMediasToDelete.length !== 0) { const idsToDelete = scrapMediasToDelete.map((id) => `'${id}'`).join(', ') await db.$executeRawUnsafe(` diff --git a/src/app/detail/[task_id]/[user_type]/actions.ts b/src/app/detail/[task_id]/[user_type]/actions.ts index 9ce9d6e3d..66f19e9d4 100644 --- a/src/app/detail/[task_id]/[user_type]/actions.ts +++ b/src/app/detail/[task_id]/[user_type]/actions.ts @@ -87,7 +87,7 @@ export const deleteAttachment = async (token: string, id: string) => { } export const postComment = async (token: string, payload: CreateComment) => { - const res = await fetch(`${apiUrl}/api/comment?token=${token}`, { + const res = await fetch(`${apiUrl}/api/comments?token=${token}`, { method: 'POST', body: JSON.stringify(payload), }) @@ -96,7 +96,7 @@ export const postComment = async (token: string, payload: CreateComment) => { } export const updateComment = async (token: string, id: string, payload: UpdateComment) => { - const res = await fetch(`${apiUrl}/api/comment/${id}?token=${token}`, { + const res = await fetch(`${apiUrl}/api/comments/${id}?token=${token}`, { method: 'PATCH', body: JSON.stringify(payload), }) @@ -105,7 +105,7 @@ export const updateComment = async (token: string, id: string, payload: UpdateCo } export const deleteComment = async (token: string, id: string) => { - await fetch(`${apiUrl}/api/comment/${id}?token=${token}`, { + await fetch(`${apiUrl}/api/comments/${id}?token=${token}`, { method: 'DELETE', }) } diff --git a/src/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 e7ceeffc3..ed8369f60 100644 --- a/src/app/detail/ui/Comments.tsx +++ b/src/app/detail/ui/Comments.tsx @@ -10,6 +10,7 @@ import { useSelector } from 'react-redux' import { VerticalLine } from './styledComponent' interface Prop { + token: string comment: LogResponse createComment: (postCommentPayload: CreateComment) => void deleteComment: (id: string, replyId?: string, softDelete?: boolean) => void @@ -18,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 ( @@ -35,6 +36,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/TaskCardList.tsx b/src/app/detail/ui/TaskCardList.tsx index e7682ec30..93b70927e 100644 --- a/src/app/detail/ui/TaskCardList.tsx +++ b/src/app/detail/ui/TaskCardList.tsx @@ -259,11 +259,7 @@ export const TaskCardList = ({ flexShrink: 1, }} > - + {(task.subtaskCount > 0 || task.isArchived) && ( @@ -316,7 +312,7 @@ export const TaskCardList = ({ }} > {task.dueDate && ( - + { const isoDate = DateStringSchema.parse(formatDate(date)) diff --git a/src/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 7ec5a9783..75ee76c9c 100644 --- a/src/app/ui/NewTaskForm.tsx +++ b/src/app/ui/NewTaskForm.tsx @@ -42,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, @@ -560,10 +561,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/cmd/backfill-initiatorType-in-comments/index.ts b/src/cmd/backfill-initiatorType-in-comments/index.ts new file mode 100644 index 000000000..f2bfca1a2 --- /dev/null +++ b/src/cmd/backfill-initiatorType-in-comments/index.ts @@ -0,0 +1,138 @@ +import DBClient from '@/lib/db' +import { Comment, CommentInitiator } from '@prisma/client' +import Bottleneck from 'bottleneck' + +const copilotAPIKey = process.env.COPILOT_API_KEY +const assemblyApiDomain = process.env.NEXT_PUBLIC_ASSEMBLY_API_DOMAIN +const COPILOT_CLIENTS_ENDPOINT = `${assemblyApiDomain}/v1/clients?limit=10000` +const COPILOT_IUS_ENDPOINT = `${assemblyApiDomain}/v1/internal-users?limit=10000` + +type InitiatorMap = Map + +const fetchWithWorkspaceKey = async (url: string, workspaceId: string) => { + const resp = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + 'X-API-KEY': `${workspaceId}/${copilotAPIKey}`, + }, + }) + + if (!resp.ok) return null + return (await resp.json())?.data ?? null +} + +const getUsersMap = async (uniqueWorkspaceIds: string[]) => { + const copilotBottleneck = new Bottleneck({ maxConcurrent: 6, minTime: 200 }) + + const workspaceInitiatorMap: Record = {} + const failedWorkspaces: string[] = [] + let completedCount = 0 + const totalWorkspaces = uniqueWorkspaceIds.length + + console.info(`Starting to fetch data for ${totalWorkspaces} workspaces...`) + + const fetchWorkspaceData = async (workspaceId: string) => { + const [client, internalUser] = await Promise.all([ + copilotBottleneck.schedule(() => fetchWithWorkspaceKey(COPILOT_CLIENTS_ENDPOINT, workspaceId)), + copilotBottleneck.schedule(() => fetchWithWorkspaceKey(COPILOT_IUS_ENDPOINT, workspaceId)), + ]) + completedCount++ + + if (!client || !internalUser) { + failedWorkspaces.push(workspaceId) + console.warn(`[${completedCount}/${totalWorkspaces}] Failed to fetch data for workspace: ${workspaceId}`) + return + } + + const initiatorMap: InitiatorMap = new Map() + internalUser.forEach((user: any) => { + initiatorMap.set(user.id, CommentInitiator.internalUser) + }) + client.forEach((c: any) => { + initiatorMap.set(c.id, CommentInitiator.client) + }) + + workspaceInitiatorMap[workspaceId] = initiatorMap + + console.info( + `[${completedCount}/${totalWorkspaces}] Fetched workspace ${workspaceId}: ${internalUser.length} internal users, ${client.length} clients`, + ) + } + + await Promise.all(uniqueWorkspaceIds.map((workspaceId) => fetchWorkspaceData(workspaceId))) + console.info(`\nCompleted fetching workspace data:`) + console.info(`Successful: ${Object.keys(workspaceInitiatorMap).length}`) + console.info(`Failed: ${failedWorkspaces.length}`) + return { workspaceInitiatorMap, failedWorkspaces } +} + +const updateComments = async ( + comments: Comment[], + workspaceInitiatorMap: Record, + db: ReturnType, +) => { + const failedEntries: Comment[] = [] + const internalUserIds: string[] = [] + const clientIds: string[] = [] + + for (const comment of comments) { + if (comment.initiatorType !== null) continue + + const initiatorMap = workspaceInitiatorMap[comment.workspaceId] + if (!initiatorMap) { + failedEntries.push(comment) + continue + } + + const initiatorType = initiatorMap.get(comment.initiatorId) + + if (!initiatorType) { + failedEntries.push(comment) + continue + } + + if (initiatorType === CommentInitiator.internalUser) { + internalUserIds.push(comment.id) + } else { + clientIds.push(comment.id) + } + } + + if (internalUserIds.length > 0) { + await db.comment.updateMany({ + where: { id: { in: internalUserIds } }, + data: { initiatorType: CommentInitiator.internalUser }, + }) + } + + if (clientIds.length > 0) { + await db.comment.updateMany({ + where: { id: { in: clientIds } }, + data: { initiatorType: CommentInitiator.client }, + }) + } + + console.info(`Updated ${internalUserIds.length} internal user comments`) + console.info(`Updated ${clientIds.length} client comments`) + console.info(`Failed entries: ${failedEntries.length}`) + + return { + updatedCount: internalUserIds.length + clientIds.length, + failedEntries, + } +} + +const run = async () => { + const db = DBClient.getInstance() + + const comments = await db.comment.findMany({ + where: { initiatorType: null }, + }) + + const uniqueWorkspaceIds = [...new Set(comments.map((t) => t.workspaceId))] + const { workspaceInitiatorMap } = await getUsersMap(uniqueWorkspaceIds) + + await updateComments(comments, workspaceInitiatorMap, db) +} + +run() diff --git a/src/components/atoms/TaskTitle.tsx b/src/components/atoms/TaskTitle.tsx index 3a6a135ce..3d100ec08 100644 --- a/src/components/atoms/TaskTitle.tsx +++ b/src/components/atoms/TaskTitle.tsx @@ -5,10 +5,9 @@ import { CopilotTooltip } from './CopilotTooltip' interface TaskTitleProps { title?: string variant?: 'board' | 'list' | 'subtasks' - isClient?: boolean } -const TaskTitle = ({ title, variant = 'board', isClient = false }: TaskTitleProps) => { +const TaskTitle = ({ title, variant = 'board' }: TaskTitleProps) => { const textRef = useRef(null) const [isOverflowing, setIsOverflowing] = useState(false) @@ -50,7 +49,6 @@ const TaskTitle = ({ title, variant = 'board', isClient = false }: TaskTitleProp flexShrink: 1, flexGrow: 0, minWidth: 0, - maxWidth: isClient ? '105px' : 'none', }} > {title} diff --git a/src/components/cards/CommentCard.tsx b/src/components/cards/CommentCard.tsx index 8b26ac56c..3c3540f03 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) @@ -158,7 +173,7 @@ export const CommentCard = ({ const replyCount = (comment.details as CommentResponse).replyCount - const cacheKey = `/api/comment/?token=${token}&parentId=${comment.details.id}` + const cacheKey = `/api/comments/?token=${token}&parentId=${comment.details.id}` const { trigger } = useSWRMutation(cacheKey, fetcher, { optimisticData: optimisticUpdates.filter((update) => update.tempId), }) @@ -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 38910e4b4..7c5049d90 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) @@ -84,14 +86,12 @@ export const CommentInput = ({ createComment, task_id }: Prop) => { /* eslint-disable-next-line react-hooks/exhaustive-deps */ // }, [detail, isListOrMenuActive, isFocused, isMobile]) // Depend on detail to ensure the latest state is captured - const uploadFn = token - ? 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 b4e6aadba..8d127733b 100644 --- a/src/components/inputs/ReplyInput.tsx +++ b/src/components/inputs/ReplyInput.tsx @@ -6,32 +6,33 @@ import { useWindowWidth } from '@/hooks/useWindowWidth' import { selectAuthDetails } from '@/redux/features/authDetailsSlice' import { selectTaskBoard } from '@/redux/features/taskBoardSlice' import { CreateComment } from '@/types/dto/comment.dto' -import { deleteEditorAttachmentsHandler } from '@/utils/inlineImage' +import { deleteEditorAttachmentsHandler } from '@/utils/attachmentUtils' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { Box, Stack } from '@mui/material' import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { Tapwrite } from 'tapwrite' +import { createUploadFn } from '@/utils/createUploadFn' interface ReplyInputProps { + token: string task_id: string comment: any createComment: (postCommentPayload: CreateComment) => void - uploadFn: ((file: File) => Promise) | undefined focusReplyInput: boolean setFocusReplyInput: Dispatch> } export const ReplyInput = ({ + token, task_id, comment, createComment, - uploadFn, focusReplyInput, setFocusReplyInput, }: ReplyInputProps) => { const [detail, setDetail] = useState('') - const { token, assignee } = useSelector(selectTaskBoard) + const { assignee, activeTask } = useSelector(selectTaskBoard) const windowWidth = useWindowWidth() const isMobile = () => { return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || windowWidth < 600 @@ -142,6 +143,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/jobs/notifications/send-reply-create-notifications.ts b/src/jobs/notifications/send-reply-create-notifications.ts index e9aa01479..8fbd54aca 100644 --- a/src/jobs/notifications/send-reply-create-notifications.ts +++ b/src/jobs/notifications/send-reply-create-notifications.ts @@ -2,8 +2,8 @@ import { NotificationSender, NotificationSenderSchema } from '@/types/common' import { getAssigneeName } from '@/utils/assignee' import { copilotBottleneck } from '@/utils/bottleneck' import { CopilotAPI } from '@/utils/CopilotAPI' -import { CommentRepository } from '@api/comment/comment.repository' -import { CommentService } from '@api/comment/comment.service' +import { CommentRepository } from '@/app/api/comments/comment.repository' +import { CommentService } from '@/app/api/comments/comment.service' import User from '@api/core/models/User.model' import { TasksService } from '@api/tasks/tasks.service' import { Comment, CommentInitiator, Task } from '@prisma/client' @@ -69,7 +69,7 @@ export const sendReplyCreateNotifications = task({ } const commentService = new CommentService(user) - const parentComment = await commentService.getCommentById(comment.parentId) + const parentComment = await commentService.getCommentById({ id: comment.parentId }) if (parentComment) { // Queue notification for parent comment initiator, if: // - Parent Comment hasn't been deleted yet diff --git a/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts b/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts index 2081c63da..d43cfadb0 100644 --- a/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts +++ b/src/jobs/webhook-dispatch/queue-task-update-backlog-webhook.ts @@ -23,7 +23,10 @@ export const queueTaskUpdatedBacklogWebhook = task({ // Extract the latest task data const task = await db.task.findFirst({ where: { id: payload.taskId }, - include: { workflowState: true }, + include: { + workflowState: true, + attachments: true, + }, }) if (!task) { throw new Error('Failed to find task for task update backlog webhook') @@ -32,7 +35,7 @@ export const queueTaskUpdatedBacklogWebhook = task({ // Dispatch webhooks const copilot = new CopilotAPI(payload.user.token) await copilot.dispatchWebhook(DISPATCHABLE_EVENT.TaskUpdated, { - payload: PublicTaskSerializer.serialize(task), + payload: await PublicTaskSerializer.serialize(task), workspaceId: payload.user.workspaceId, }) 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/dto/comment.dto.ts b/src/types/dto/comment.dto.ts index da92c41dd..d81b2976b 100644 --- a/src/types/dto/comment.dto.ts +++ b/src/types/dto/comment.dto.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { AttachmentResponseSchema } from './attachments.dto' +import { Attachment, Comment } from '@prisma/client' export const CreateCommentSchema = z.object({ content: z.string(), @@ -37,3 +38,13 @@ export const CommentResponseSchema: z.ZodType = z.lazy(() => ) export type CommentResponse = z.infer + +export type CommentWithAttachments = Comment & { attachments: Attachment[] } + +export type CommentsPublicFilterType = { + taskId?: string + parentId?: string + initiatorId?: string + limit?: number + lastIdCursor?: string +} 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/types/webhook.ts b/src/types/webhook.ts index c396087dd..c3b43791d 100644 --- a/src/types/webhook.ts +++ b/src/types/webhook.ts @@ -14,6 +14,7 @@ export enum DISPATCHABLE_EVENT { TaskUpdated = 'task.updated', TaskCompleted = 'task.completed', TaskDeleted = 'task.deleted', + CommentCreated = 'comment.created', } export const WebhookSchema = z.object({ diff --git a/src/utils/SupabaseActions.ts b/src/utils/SupabaseActions.ts index 3856608e1..98068230a 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 @@ -23,6 +28,15 @@ export class SupabaseActions extends SupabaseService { console.error('unable to upload the file') } if (data) { + const { error: metadataError } = await this.supabase.storage.from(supabaseBucket).update(data.path, file, { + metadata: { + originalFileName: file.name, + }, + }) + if (metadataError) { + console.error('Failed to update metadata:', metadataError) + } + filePayload = { fileSize: file.size, fileName: file.name, 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?.() + } + } +} diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts new file mode 100644 index 000000000..a61f7c54b --- /dev/null +++ b/src/utils/pagination.ts @@ -0,0 +1,21 @@ +import { defaultLimit } from '@/constants/public-api' +import z from 'zod' + +type PrismaPaginationArgs = { + take?: number + skip?: number + cursor?: { id: string } +} + +export function getBasicPaginationAttributes(limit?: number, lastIdCursor?: string): PrismaPaginationArgs { + return { + take: limit, + cursor: lastIdCursor ? { id: lastIdCursor } : undefined, + skip: lastIdCursor ? 1 : undefined, + } +} + +export function getPaginationLimit(limit?: number | string | null) { + const safeLimit = z.coerce.number().safeParse(limit) + return !safeLimit.success || !safeLimit.data ? defaultLimit : safeLimit.data +} diff --git a/src/utils/sanitizeFileName.ts b/src/utils/sanitizeFileName.ts new file mode 100644 index 000000000..a97d2a279 --- /dev/null +++ b/src/utils/sanitizeFileName.ts @@ -0,0 +1,10 @@ +/** + * Sanitizes a Supabase stored filename back to its original format + * Removes UUID prefix and the underscore following it. ONLY TO BE USED on attachment response for public APIs. + * + * @param fileName - The stored filename with UUID prefix + * @returns The original filename + */ +export function sanitizeFileName(fileName: string): string { + return fileName.replace(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/i, '') //remove the initial UUID. +} diff --git a/src/utils/santizeContents.ts b/src/utils/santizeContents.ts new file mode 100644 index 000000000..2198d1a07 --- /dev/null +++ b/src/utils/santizeContents.ts @@ -0,0 +1,12 @@ +/** A utility function that strips the attachment tags, image tags and all its content from task content or comment content. ONLY TO BE USED FOR PUBLIC API. + * + * @export + * @param {string} html : takes in the description of a task or content of a comment + * @returns {string} : returns the sanitized content removing useless tags causing pollution in the public API. + */ +export function sanitizeHtml(html: string): string { + let sanitized = html.replace(/]*>/gi, '') + sanitized = sanitized.replace(/]*>[\s\S]*?<\/div>/gi, '') + sanitized = sanitized.replace(/

\s*<\/p>/gi, '') + return sanitized +} diff --git a/src/utils/signUrl.ts b/src/utils/signUrl.ts index ef5a20499..a66e85dd5 100644 --- a/src/utils/signUrl.ts +++ b/src/utils/signUrl.ts @@ -11,6 +11,15 @@ export const getSignedUrl = async (filePath: string) => { return url } // used to replace urls for images in task body +export const createSignedUrls = async (filePaths: string[]) => { + const supabase = new SupabaseService() + const { data, error } = await supabase.supabase.storage.from(supabaseBucket).createSignedUrls(filePaths, signedUrlTtl) + if (error) { + throw new Error(error.message) + } + return data +} + export const getFileNameFromSignedUrl = (url: string) => { // Aggressive regex that selects string from last '/'' to url param (starting with ?) const regex = /.*\/([^\/\?]+)(?:\?.*)?$/