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/src/app/api/comment/comment.service.ts b/src/app/api/comment/comment.service.ts index 2e43850a0..8e9e18876 100755 --- a/src/app/api/comment/comment.service.ts +++ b/src/app/api/comment/comment.service.ts @@ -1,8 +1,9 @@ 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 { CommentsPublicFilterType, CommentWithAttachments, CreateComment, UpdateComment } from '@/types/dto/comment.dto' import { getArrayDifference, getArrayIntersection } from '@/utils/array' +import { getBasicPaginationAttributes } from '@/utils/pagination' import { CommentAddedSchema } from '@api/activity-logs/schemas/CommentAddedSchema' import { ActivityLogger } from '@api/activity-logs/services/activity-logger.service' import { CommentRepository } from '@api/comment/comment.repository' @@ -12,7 +13,7 @@ import { PoliciesService } from '@api/core/services/policies.service' import { Resource } from '@api/core/types/api' import { UserAction } from '@api/core/types/user' import { TasksService } from '@api/tasks/tasks.service' -import { ActivityType, Comment, CommentInitiator } from '@prisma/client' +import { ActivityType, Comment, CommentInitiator, Prisma } from '@prisma/client' import httpStatus from 'http-status' import { z } from 'zod' @@ -266,4 +267,33 @@ export class CommentService extends BaseService { return { ...comment, initiator } }) } + + async getAllComments(queryFilters: CommentsPublicFilterType): Promise { + const { parentId, taskId, limit, lastIdCursor, initiatorId } = queryFilters + const where = { + parentId, + taskId, + initiatorId, + workspaceId: this.user.workspaceId, + } + + const pagination = getBasicPaginationAttributes(limit, lastIdCursor) + + return await this.db.comment.findMany({ + where, + ...pagination, + include: { attachments: true }, + orderBy: { createdAt: 'desc' }, + }) + } + + async hasMoreCommentsAfterCursor(id: string, publicFilters: Partial): Promise { + const newComment = await this.db.comment.findFirst({ + where: { ...publicFilters, workspaceId: this.user.workspaceId }, + cursor: { id }, + skip: 1, + orderBy: { createdAt: 'desc' }, + }) + return !!newComment + } } diff --git a/src/app/api/comment/public/comment-public.controller.ts b/src/app/api/comment/public/comment-public.controller.ts new file mode 100644 index 000000000..9a9f4c521 --- /dev/null +++ b/src/app/api/comment/public/comment-public.controller.ts @@ -0,0 +1,46 @@ +import { CommentService } from '@/app/api/comment/comment.service' +import authenticate from '@/app/api/core/utils/authenticate' +import { defaultLimit } from '@/constants/public-api' +import { getSearchParams } from '@/utils/request' +import { NextRequest, NextResponse } from 'next/server' +import { decode, encode } from 'js-base64' +import { PublicCommentSerializer } from '@/app/api/comment/public/comment-public.serializer' +import { CommentsPublicFilterType } from '@/types/dto/comment.dto' +import { IdParams } from '@/app/api/core/types/api' +import { getPaginationLimit } from '@/utils/pagination' + +export const getAllCommentsPublicForTask = async (req: NextRequest, { params }: IdParams) => { + const { id } = await params + const user = await authenticate(req) + + const { parentCommentId, createdBy, limit, nextToken } = getSearchParams(req.nextUrl.searchParams, [ + 'parentCommentId', + 'createdBy', + 'limit', + 'nextToken', + ]) + + const publicFilters: CommentsPublicFilterType = { + taskId: id, + parentId: parentCommentId || undefined, + initiatorId: createdBy || undefined, + } + + const commentService = new CommentService(user) + 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, + }) +} diff --git a/src/app/api/comment/public/comment-public.dto.ts b/src/app/api/comment/public/comment-public.dto.ts new file mode 100644 index 000000000..2bf52547a --- /dev/null +++ b/src/app/api/comment/public/comment-public.dto.ts @@ -0,0 +1,29 @@ +import { RFC3339DateSchema } from '@/types/common' +import { AssigneeType } from '@prisma/client' +import z from 'zod' + +export const PublicAttachmentDtoSchema = z.object({ + id: z.string().uuid(), + fileName: z.string(), + fileSize: z.number(), + mimeType: z.string(), + downloadUrl: z.string().url(), + uploadedBy: z.string().uuid(), + uploadedByUserType: z.nativeEnum(AssigneeType).nullable(), + uploadedDate: RFC3339DateSchema, +}) +export type PublicAttachmentDto = z.infer + +export const PublicCommentDtoSchema = z.object({ + id: z.string().uuid(), + object: z.literal('taskComment'), + taskId: z.string().uuid(), + parentCommentId: z.string().uuid().nullable(), + content: z.string(), + createdBy: z.string().uuid(), + createdByUserType: z.nativeEnum(AssigneeType).nullable(), + createdDate: RFC3339DateSchema, + updatedDate: RFC3339DateSchema, + attachments: z.array(PublicAttachmentDtoSchema).nullable(), +}) +export type PublicCommentDto = z.infer diff --git a/src/app/api/comment/public/comment-public.serializer.ts b/src/app/api/comment/public/comment-public.serializer.ts new file mode 100644 index 000000000..e5a3295fd --- /dev/null +++ b/src/app/api/comment/public/comment-public.serializer.ts @@ -0,0 +1,80 @@ +import { PublicAttachmentDto, PublicCommentDto, PublicCommentDtoSchema } from '@/app/api/comment/public/comment-public.dto' +import { RFC3339DateSchema } from '@/types/common' +import { CommentWithAttachments } from '@/types/dto/comment.dto' +import { toRFC3339 } from '@/utils/dateHelper' +import { createSignedUrls } from '@/utils/signUrl' +import { Attachment, CommentInitiator } from '@prisma/client' +import { z } from 'zod' + +export class PublicCommentSerializer { + static async serializeUnsafe(comment: CommentWithAttachments): Promise { + return { + id: comment.id, + object: 'taskComment', + parentCommentId: comment.parentId, + taskId: comment.taskId, + content: comment.content, + createdBy: comment.initiatorId, + createdByUserType: comment.initiatorType, + createdDate: RFC3339DateSchema.parse(toRFC3339(comment.createdAt)), + updatedDate: RFC3339DateSchema.parse(toRFC3339(comment.updatedAt)), + attachments: await PublicCommentSerializer.serializeAttachments({ + attachments: comment.attachments, + uploadedByUserType: comment.initiatorType, + uploadedBy: comment.initiatorId, + }), + } + } + + /** + * + * @param attachments array of Attachment + * @param uploadedBy id of the one who commented + * @param uploadedByUserType usertype of the one who commented + * @returns Array of PublicAttachmentDto + */ + static async serializeAttachments({ + attachments, + uploadedByUserType, + uploadedBy, + }: { + attachments: Attachment[] + uploadedByUserType: CommentInitiator | null + uploadedBy: string + }): Promise { + const attachmentPaths = attachments.map((attachment) => attachment.filePath) + const signedUrls = await PublicCommentSerializer.getFormattedSignedUrls(attachmentPaths) + + return attachments.map((attachment) => { + const url = signedUrls.find((item) => item.path === attachment.filePath)?.url + return { + id: attachment.id, + fileName: attachment.fileName, + fileSize: attachment.fileSize, + mimeType: attachment.fileType, + downloadUrl: z + .string() + .url({ message: `Invalid downloadUrl for attachment with id ${attachment.id}` }) + .parse(url), + uploadedBy, + uploadedByUserType, + uploadedDate: RFC3339DateSchema.parse(toRFC3339(attachment.createdAt)), + } + }) + } + + static async serialize(comment: CommentWithAttachments): Promise { + return PublicCommentDtoSchema.parse(await PublicCommentSerializer.serializeUnsafe(comment)) + } + + static async serializeMany(comments: CommentWithAttachments[]): Promise { + const serializedComments = await Promise.all(comments.map(async (comment) => PublicCommentSerializer.serialize(comment))) + return z.array(PublicCommentDtoSchema).parse(serializedComments) + } + + static async getFormattedSignedUrls(attachmentPaths: string[]) { + if (!attachmentPaths.length) return [] + const signedUrls = await createSignedUrls(attachmentPaths) + return signedUrls.map((item) => ({ path: item.path, url: item.signedUrl })) + } +} diff --git a/src/app/api/tasks/public/[id]/comments/route.ts b/src/app/api/tasks/public/[id]/comments/route.ts new file mode 100644 index 000000000..56fa05626 --- /dev/null +++ b/src/app/api/tasks/public/[id]/comments/route.ts @@ -0,0 +1,4 @@ +import { getAllCommentsPublicForTask } from '@/app/api/comment/public/comment-public.controller' +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' + +export const GET = withErrorHandler(getAllCommentsPublicForTask) diff --git a/src/app/api/tasks/public/public.service.ts b/src/app/api/tasks/public/public.service.ts index f4f3745f7..d1fef0c3e 100644 --- a/src/app/api/tasks/public/public.service.ts +++ b/src/app/api/tasks/public/public.service.ts @@ -24,6 +24,7 @@ import { SubtaskService } from '@api/tasks/subtasks.service' import { TasksActivityLogger } from '@api/tasks/tasks.logger' import { TemplatesService } from '@api/tasks/templates/templates.service' import { PublicTaskSerializer } from '@api/tasks/public/public.serializer' +import { getBasicPaginationAttributes } from '@/utils/pagination' export class PublicTasksService extends TasksSharedService { async getAllTasks(queryFilters: { @@ -80,11 +81,7 @@ export class PublicTasksService extends TasksSharedService { } const orderBy: Prisma.TaskOrderByWithRelationInput[] = [{ createdAt: 'desc' }] - const pagination: Prisma.TaskFindManyArgs = { - take: queryFilters.limit, - cursor: queryFilters.lastIdCursor ? { id: queryFilters.lastIdCursor } : undefined, - skip: queryFilters.lastIdCursor ? 1 : undefined, - } + const pagination = getBasicPaginationAttributes(queryFilters.limit, queryFilters.lastIdCursor) const tasks = await this.db.task.findMany({ where, diff --git a/src/types/dto/comment.dto.ts b/src/types/dto/comment.dto.ts index da92c41dd..326a74a41 100644 --- a/src/types/dto/comment.dto.ts +++ b/src/types/dto/comment.dto.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { AttachmentResponseSchema } from './attachments.dto' +import { Attachment, Comment } from '@prisma/client' export const CreateCommentSchema = z.object({ content: z.string(), @@ -37,3 +38,13 @@ export const CommentResponseSchema: z.ZodType = z.lazy(() => ) export type CommentResponse = z.infer + +export type CommentWithAttachments = Comment & { attachments: Attachment[] } + +export type CommentsPublicFilterType = { + taskId: string + parentId?: string + initiatorId?: string + limit?: number + lastIdCursor?: string +} 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/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 = /.*\/([^\/\?]+)(?:\?.*)?$/