Skip to content
Draft
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
2 changes: 1 addition & 1 deletion src/app/api/attachments/public/public.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})

Expand Down
2 changes: 1 addition & 1 deletion src/app/api/attachments/public/public.serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class PublicAttachmentSerializer {
uploadedBy,
}: {
attachments: Attachment[]
uploadedByUserType: CommentInitiator | null
uploadedByUserType: CommentInitiator
content: string | null
uploadedBy?: string
}): Promise<PublicAttachmentDto[]> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
43 changes: 41 additions & 2 deletions src/app/api/comments/comment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})

Expand Down Expand Up @@ -323,6 +324,44 @@ export class CommentService extends BaseService {
})
}

async resolveCommentInitiatorTypes<T extends { initiatorId: string; initiatorType: CommentInitiator | null }>(
comments: T[],
): Promise<Array<T & { initiatorType: CommentInitiator }>> {
if (!comments.length) return []

const needsResolution = comments.some((comment) => comment.initiatorType === null)
if (!needsResolution) {
return comments as Array<T & { initiatorType: CommentInitiator }>
}

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 }[] = []

Expand Down
12 changes: 9 additions & 3 deletions src/app/api/comments/public/public.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Expand All @@ -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) => {
Expand All @@ -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)) })
}
2 changes: 1 addition & 1 deletion src/app/api/comments/public/public.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
44 changes: 44 additions & 0 deletions src/app/api/label-mapping/label-mapping.service.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
3 changes: 2 additions & 1 deletion src/app/api/label-mapping/label-mapping.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
})
}
Expand Down
Loading