Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "IX_Comments_taskId_workspaceId_createdAt" ON "Comments"("taskId", "workspaceId", "createdAt" DESC);
1 change: 1 addition & 0 deletions prisma/schema/comment.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ model Comment {
deletedAt DateTime? @db.Timestamptz()

@@map("Comments")
@@index([taskId, workspaceId, createdAt(sort: Desc)], name: "IX_Comments_taskId_workspaceId_createdAt")
}
34 changes: 32 additions & 2 deletions src/app/api/comment/comment.service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -266,4 +267,33 @@ export class CommentService extends BaseService {
return { ...comment, initiator }
})
}

async getAllComments(queryFilters: CommentsPublicFilterType): Promise<CommentWithAttachments[]> {
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' },
})
}
Comment on lines +282 to +288
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the comments table properly indexed here, can you please check

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

created composite index with taskId, workspaceId, createdAt DESC.


async hasMoreCommentsAfterCursor(id: string, publicFilters: Partial<CommentsPublicFilterType>): Promise<boolean> {
const newComment = await this.db.comment.findFirst({
where: { ...publicFilters, workspaceId: this.user.workspaceId },
cursor: { id },
skip: 1,
orderBy: { createdAt: 'desc' },
})
return !!newComment
}
}
46 changes: 46 additions & 0 deletions src/app/api/comment/public/comment-public.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
})
}
29 changes: 29 additions & 0 deletions src/app/api/comment/public/comment-public.dto.ts
Original file line number Diff line number Diff line change
@@ -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<typeof PublicAttachmentDtoSchema>

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<typeof PublicCommentDtoSchema>
80 changes: 80 additions & 0 deletions src/app/api/comment/public/comment-public.serializer.ts
Original file line number Diff line number Diff line change
@@ -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<PublicCommentDto> {
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<PublicAttachmentDto[]> {
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<PublicCommentDto> {
return PublicCommentDtoSchema.parse(await PublicCommentSerializer.serializeUnsafe(comment))
}

static async serializeMany(comments: CommentWithAttachments[]): Promise<PublicCommentDto[]> {
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 }))
}
}
4 changes: 4 additions & 0 deletions src/app/api/tasks/public/[id]/comments/route.ts
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 2 additions & 5 deletions src/app/api/tasks/public/public.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/types/dto/comment.dto.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -37,3 +38,13 @@ export const CommentResponseSchema: z.ZodType = z.lazy(() =>
)

export type CommentResponse = z.infer<typeof CommentResponseSchema>

export type CommentWithAttachments = Comment & { attachments: Attachment[] }

export type CommentsPublicFilterType = {
taskId: string
parentId?: string
initiatorId?: string
limit?: number
lastIdCursor?: string
}
21 changes: 21 additions & 0 deletions src/utils/pagination.ts
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions src/utils/signUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /.*\/([^\/\?]+)(?:\?.*)?$/
Expand Down
Loading