import { ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { LabCaseCommentSide, Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; const commentInclude = { authorUser: { select: { id: true, name: true } }, authorOrganization: { select: { id: true, name: true } }, } satisfies Prisma.LabCaseCommentInclude; type CommentWithRelations = Prisma.LabCaseCommentGetPayload<{ include: typeof commentInclude; }>; @Injectable() export class LabCaseCommentsService { constructor(private readonly prisma: PrismaService) {} // ---------- Lab side (TAB_TASKS_EDIT) ---------- async listForLab(caseId: string, labOrganizationId: string, actorUserId: string) { await this.assertLabCanComment(caseId, labOrganizationId, actorUserId); const comments = await this.fetchComments(caseId); return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) }; } async addForLab( caseId: string, labOrganizationId: string, actorUserId: string, dto: CreateLabCaseCommentDto, ) { await this.assertLabCanComment(caseId, labOrganizationId, actorUserId); const created = await this.prisma.labCaseComment.create({ data: { labCaseId: caseId, authorUserId: actorUserId, authorOrganizationId: labOrganizationId, authorSide: LabCaseCommentSide.LAB, body: dto.body.trim(), visibleToClinic: dto.visibleToClinic ?? false, }, include: commentInclude, }); return { success: true, data: this.mapComment(created, LabCaseCommentSide.LAB) }; } async setVisibility( commentId: string, labOrganizationId: string, actorUserId: string, visibleToClinic: boolean, ) { const comment = await this.prisma.labCaseComment.findUnique({ where: { id: commentId }, select: { id: true, labCaseId: true, authorSide: true }, }); if (!comment) { throw new NotFoundException('Comment not found'); } await this.assertLabCanComment(comment.labCaseId, labOrganizationId, actorUserId); if (comment.authorSide !== LabCaseCommentSide.LAB) { throw new ForbiddenException('Only lab comments can change visibility'); } const updated = await this.prisma.labCaseComment.update({ where: { id: commentId }, data: { visibleToClinic }, include: commentInclude, }); return { success: true, data: this.mapComment(updated, LabCaseCommentSide.LAB) }; } // ---------- Clinic side (connection access is validated by caller) ---------- async listForClinic(caseId: string, clinicOrganizationId: string) { await this.assertClinicOwnsCase(caseId, clinicOrganizationId); const comments = await this.fetchComments(caseId, { visibleOnly: true }); return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)), }; } async addForClinic( caseId: string, clinicOrganizationId: string, actorUserId: string, dto: CreateLabCaseCommentDto, ) { await this.assertClinicOwnsCase(caseId, clinicOrganizationId); const created = await this.prisma.labCaseComment.create({ data: { labCaseId: caseId, authorUserId: actorUserId, authorOrganizationId: clinicOrganizationId, authorSide: LabCaseCommentSide.CLINIC, body: dto.body.trim(), // Clinic-authored comments are inherently visible to the clinic. visibleToClinic: true, }, include: commentInclude, }); return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; } // ---------- Helpers ---------- private fetchComments(caseId: string, opts?: { visibleOnly?: boolean }) { return this.prisma.labCaseComment.findMany({ where: { labCaseId: caseId, ...(opts?.visibleOnly ? { visibleToClinic: true } : {}), }, include: commentInclude, orderBy: { createdAt: 'asc' }, }); } private mapComment(comment: CommentWithRelations, viewerSide: LabCaseCommentSide) { return { id: comment.id, body: comment.body, authorSide: comment.authorSide, authorName: comment.authorUser?.name ?? null, authorOrganizationName: comment.authorOrganization?.name ?? null, visibleToClinic: comment.visibleToClinic, createdAt: comment.createdAt.toISOString(), // Only lab viewers can toggle visibility, and only on lab-authored comments. canToggleVisibility: viewerSide === LabCaseCommentSide.LAB && comment.authorSide === LabCaseCommentSide.LAB, }; } private async assertLabCanComment( caseId: string, labOrganizationId: string, actorUserId: string, ) { const labCase = await this.prisma.labCase.findFirst({ where: { id: caseId, sentAt: { not: null }, sends: { some: { organizationId: labOrganizationId } }, }, select: { id: true }, }); if (!labCase) { throw new NotFoundException('Case not found'); } const membership = await this.prisma.membership.findFirst({ where: { userId: actorUserId, organizationId: labOrganizationId, isActive: true }, include: { permissions: { include: { permission: true } } }, }); if (!membership) { throw new ForbiddenException('You are not a member of this organization'); } if (membership.isOwner) return; const names = membership.permissions.map((p) => p.permission.name); if (!names.includes('TAB_TASKS_EDIT')) { throw new ForbiddenException('You do not have access to task comments'); } } private async assertClinicOwnsCase(caseId: string, clinicOrganizationId: string) { const labCase = await this.prisma.labCase.findFirst({ where: { id: caseId, sentAt: { not: null }, treatment: { organizationId: clinicOrganizationId }, }, select: { id: true }, }); if (!labCase) { throw new NotFoundException('Case not found'); } } }