diff --git a/src/app/api/attachments/public/public.dto.ts b/src/app/api/attachments/public/public.dto.ts index 4b960a717..94134666c 100644 --- a/src/app/api/attachments/public/public.dto.ts +++ b/src/app/api/attachments/public/public.dto.ts @@ -9,7 +9,7 @@ export const PublicAttachmentDtoSchema = z.object({ mimeType: z.string(), downloadUrl: z.string().url().nullable(), uploadedBy: z.string().uuid(), - uploadedByUserType: z.nativeEnum(AssigneeType).nullable(), + uploadedByUserType: z.nativeEnum(AssigneeType), uploadedDate: RFC3339DateSchema, }) diff --git a/src/app/api/attachments/public/public.serializer.ts b/src/app/api/attachments/public/public.serializer.ts index 9757673d5..ce13819e0 100644 --- a/src/app/api/attachments/public/public.serializer.ts +++ b/src/app/api/attachments/public/public.serializer.ts @@ -21,7 +21,7 @@ export class PublicAttachmentSerializer { uploadedBy, }: { attachments: Attachment[] - uploadedByUserType: CommentInitiator | null + uploadedByUserType: CommentInitiator content: string | null uploadedBy?: string }): Promise { diff --git a/src/app/api/comments/comment.service.resolve-initiator-type.test.ts b/src/app/api/comments/comment.service.resolve-initiator-type.test.ts new file mode 100644 index 000000000..77aa51960 --- /dev/null +++ b/src/app/api/comments/comment.service.resolve-initiator-type.test.ts @@ -0,0 +1,86 @@ +jest.mock('@/lib/db', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(() => ({})), + }, +})) +jest.mock('@/utils/CopilotAPI', () => ({ CopilotAPI: class {} })) + +import { CommentService } from '@/app/api/comments/comment.service' +import User from '@api/core/models/User.model' +import { CommentInitiator } from '@prisma/client' + +const INTERNAL_USER_ID = '11111111-1111-1111-1111-111111111111' +const CLIENT_ID = '22222222-2222-2222-2222-222222222222' +const UNKNOWN_ID = '33333333-3333-3333-3333-333333333333' + +const createService = ({ + internalUserIds = [INTERNAL_USER_ID], + clientIds = [CLIENT_ID], + internalUserLookupFails = false, +}: { + internalUserIds?: string[] + clientIds?: string[] + internalUserLookupFails?: boolean +} = {}) => { + const user = new User('test-token', { + workspaceId: 'workspace-1', + internalUserId: INTERNAL_USER_ID, + }) + + const service = new CommentService(user) + const mockCopilot = { + getInternalUsers: jest.fn().mockResolvedValue({ data: internalUserIds.map((id) => ({ id })) }), + getClients: jest.fn().mockResolvedValue({ data: clientIds.map((id) => ({ id })) }), + getInternalUser: jest + .fn() + .mockImplementation(async (id: string) => (internalUserLookupFails ? Promise.reject(new Error('not found')) : { id })), + } + Object.assign(service, { copilot: mockCopilot }) + + return { service, mockCopilot } +} + +describe('CommentService#resolveCommentInitiatorTypes', () => { + it('returns comments unchanged when initiatorType is already set', async () => { + const { service, mockCopilot } = createService() + const comments = [ + { id: 'comment-1', initiatorId: INTERNAL_USER_ID, initiatorType: CommentInitiator.internalUser }, + ] + + const result = await service.resolveCommentInitiatorTypes(comments) + + expect(result).toEqual(comments) + expect(mockCopilot.getInternalUsers).not.toHaveBeenCalled() + }) + + it('resolves null initiatorType from internal user list', async () => { + const { service } = createService() + const comments = [{ id: 'comment-1', initiatorId: INTERNAL_USER_ID, initiatorType: null }] + + const result = await service.resolveCommentInitiatorTypes(comments) + + expect(result).toEqual([ + { id: 'comment-1', initiatorId: INTERNAL_USER_ID, initiatorType: CommentInitiator.internalUser }, + ]) + }) + + it('resolves null initiatorType from client list', async () => { + const { service } = createService() + const comments = [{ id: 'comment-1', initiatorId: CLIENT_ID, initiatorType: null }] + + const result = await service.resolveCommentInitiatorTypes(comments) + + expect(result).toEqual([{ id: 'comment-1', initiatorId: CLIENT_ID, initiatorType: CommentInitiator.client }]) + }) + + it('falls back to client when initiator is not in bulk lists and internal user lookup fails', async () => { + const { service, mockCopilot } = createService({ internalUserIds: [], clientIds: [], internalUserLookupFails: true }) + const comments = [{ id: 'comment-1', initiatorId: UNKNOWN_ID, initiatorType: null }] + + const result = await service.resolveCommentInitiatorTypes(comments) + + expect(result).toEqual([{ id: 'comment-1', initiatorId: UNKNOWN_ID, initiatorType: CommentInitiator.client }]) + expect(mockCopilot.getInternalUser).toHaveBeenCalledWith(UNKNOWN_ID) + }) +}) diff --git a/src/app/api/comments/comment.service.ts b/src/app/api/comments/comment.service.ts index f373e3681..46c1d7d96 100755 --- a/src/app/api/comments/comment.service.ts +++ b/src/app/api/comments/comment.service.ts @@ -97,9 +97,10 @@ export class CommentService extends BaseService { ]) } - // dispatch a webhook event when comment is created + const [commentForWebhook] = await this.resolveCommentInitiatorTypes([commentToReturn]) + await this.copilot.dispatchWebhook(DISPATCHABLE_EVENT.CommentCreated, { - payload: await PublicCommentSerializer.serialize(commentToReturn), + payload: await PublicCommentSerializer.serialize(commentForWebhook), workspaceId: this.user.workspaceId, }) @@ -323,6 +324,44 @@ export class CommentService extends BaseService { }) } + async resolveCommentInitiatorTypes( + comments: T[], + ): Promise> { + if (!comments.length) return [] + + const needsResolution = comments.some((comment) => comment.initiatorType === null) + if (!needsResolution) { + return comments as Array + } + + const [internalUsers, clients] = await Promise.all([this.copilot.getInternalUsers(), this.copilot.getClients()]) + const internalUserIds = new Set(internalUsers.data.map((user) => user.id)) + const clientIds = new Set(clients?.data?.map((client) => client.id) ?? []) + + return Promise.all( + comments.map(async (comment) => { + if (comment.initiatorType !== null) { + return comment as T & { initiatorType: CommentInitiator } + } + + if (internalUserIds.has(comment.initiatorId)) { + return { ...comment, initiatorType: CommentInitiator.internalUser } + } + + if (clientIds.has(comment.initiatorId)) { + return { ...comment, initiatorType: CommentInitiator.client } + } + + try { + await this.copilot.getInternalUser(comment.initiatorId) + return { ...comment, initiatorType: CommentInitiator.internalUser } + } catch { + return { ...comment, initiatorType: CommentInitiator.client } + } + }), + ) + } + private async updateCommentIdOfAttachmentsAfterCreation(htmlString: string, task_id: string, commentId: string) { const replacements: { originalSrc: string; newUrl: string }[] = [] diff --git a/src/app/api/comments/public/public.controller.ts b/src/app/api/comments/public/public.controller.ts index 75d56ed27..99f4f5704 100644 --- a/src/app/api/comments/public/public.controller.ts +++ b/src/app/api/comments/public/public.controller.ts @@ -47,8 +47,10 @@ export const getAllCommentsPublic = async (req: NextRequest) => { : false const base64NextToken = hasMoreComments ? encode(lastCommentId) : undefined + const commentsWithInitiatorType = await commentService.resolveCommentInitiatorTypes(comments) + return NextResponse.json({ - data: await PublicCommentSerializer.serializeMany(comments), + data: await PublicCommentSerializer.serializeMany(commentsWithInitiatorType), nextToken: base64NextToken, }) } @@ -64,7 +66,9 @@ export const getOneCommentPublic = async (req: NextRequest, { params }: TaskAndC await commentService.checkCommentTaskPermissionForUser(comment.taskId) // check the user accessing the comment has access to the task - return NextResponse.json({ data: await PublicCommentSerializer.serialize(comment) }) + const [commentWithInitiatorType] = await commentService.resolveCommentInitiatorTypes([comment]) + + return NextResponse.json({ data: await PublicCommentSerializer.serialize(commentWithInitiatorType) }) } export const deleteOneCommentPublic = async (req: NextRequest, { params }: TaskAndCommentIdParams) => { @@ -78,5 +82,7 @@ export const deleteOneCommentPublic = async (req: NextRequest, { params }: TaskA await commentService.checkCommentTaskPermissionForUser(deletedComment.taskId) // check the user accessing the comment has access to the task - return NextResponse.json({ ...(await PublicCommentSerializer.serialize(deletedComment)) }) + const [deletedCommentWithInitiatorType] = await commentService.resolveCommentInitiatorTypes([deletedComment]) + + return NextResponse.json({ ...(await PublicCommentSerializer.serialize(deletedCommentWithInitiatorType)) }) } diff --git a/src/app/api/comments/public/public.dto.ts b/src/app/api/comments/public/public.dto.ts index f1ed9495f..55ab2f704 100644 --- a/src/app/api/comments/public/public.dto.ts +++ b/src/app/api/comments/public/public.dto.ts @@ -10,7 +10,7 @@ export const PublicCommentDtoSchema = z.object({ parentCommentId: z.string().uuid().nullable(), content: z.string(), createdBy: z.string().uuid(), - createdByUserType: z.nativeEnum(AssigneeType).nullable(), + createdByUserType: z.nativeEnum(AssigneeType), createdDate: RFC3339DateSchema, updatedDate: RFC3339DateSchema, deletedDate: RFC3339DateSchema.nullable(), diff --git a/src/app/api/label-mapping/label-mapping.service.test.ts b/src/app/api/label-mapping/label-mapping.service.test.ts new file mode 100644 index 000000000..24a195ede --- /dev/null +++ b/src/app/api/label-mapping/label-mapping.service.test.ts @@ -0,0 +1,44 @@ +const mockLabelFindFirst = jest.fn() +const mockLabelDelete = jest.fn() + +jest.mock('@/lib/db', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + label: { findFirst: mockLabelFindFirst, delete: mockLabelDelete }, + }), + }, +})) + +jest.mock('@/utils/CopilotAPI', () => ({ CopilotAPI: jest.fn() })) + +import { LabelMappingService } from '@api/label-mapping/label-mapping.service' +import User from '@api/core/models/User.model' +import { UserRole } from '@api/core/types/user' + +const user = { + workspaceId: 'ws-1', + role: UserRole.IU, + internalUserId: 'iu-1', + token: 'token', +} as unknown as User + +describe('LabelMappingService#deleteLabel', () => { + beforeEach(() => jest.clearAllMocks()) + + it('deletes the matching label row', async () => { + mockLabelFindFirst.mockResolvedValue({ id: 'label-1' }) + + await new LabelMappingService(user).deleteLabel('ASS10-009') + + expect(mockLabelDelete).toHaveBeenCalledWith({ where: { id: 'label-1' } }) + }) + + it('no-ops when the label row is already gone', async () => { + mockLabelFindFirst.mockResolvedValue(null) + + await new LabelMappingService(user).deleteLabel('ASS10-009') + + expect(mockLabelDelete).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/api/label-mapping/label-mapping.service.ts b/src/app/api/label-mapping/label-mapping.service.ts index 42007a03c..b0d9c2abd 100644 --- a/src/app/api/label-mapping/label-mapping.service.ts +++ b/src/app/api/label-mapping/label-mapping.service.ts @@ -175,9 +175,10 @@ export class LabelMappingService extends BaseService { label, }, }) + if (!currentLabel) return await this.db.label.delete({ where: { - id: currentLabel?.id, + id: currentLabel.id, }, }) }