import { ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { LabCaseCommentSide, LabCaseActivityType, Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; 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, private readonly labCaseActivity: LabCaseActivityService, ) {} // ---------- 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, }); await this.labCaseActivity.record({ labCaseId: caseId, type: LabCaseActivityType.LAB_COMMENT, actorUserId, payload: { commentId: created.id, visibleToClinic: created.visibleToClinic, }, }); 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 history) ---------- async listForClinic(caseId: string, clinicOrganizationId: string) { await this.assertClinicOwnsCase(caseId, clinicOrganizationId, { requireSent: true }); const comments = await this.fetchCommentsForClinicViewer(caseId); 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, { requireSent: true }); const created = await this.prisma.labCaseComment.create({ data: { labCaseId: caseId, authorUserId: actorUserId, authorOrganizationId: clinicOrganizationId, authorSide: LabCaseCommentSide.CLINIC, body: dto.body.trim(), visibleToClinic: true, }, include: commentInclude, }); await this.labCaseActivity.record({ labCaseId: caseId, type: LabCaseActivityType.CLINIC_COMMENT, actorUserId, payload: { commentId: created.id }, }); return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; } // ---------- Clinic side (treatment dispatch — unsent cases allowed) ---------- async listForClinicTreatmentCase( caseId: string, clinicOrganizationId: string, actorUserId: string, ) { await this.assertClinicTreatmentAccess(caseId, clinicOrganizationId, actorUserId); const comments = await this.fetchCommentsForClinicViewer(caseId); return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)), }; } async addForClinicTreatmentCase( caseId: string, clinicOrganizationId: string, actorUserId: string, dto: CreateLabCaseCommentDto, ) { await this.assertClinicTreatmentAccess(caseId, clinicOrganizationId, actorUserId); const created = await this.prisma.labCaseComment.create({ data: { labCaseId: caseId, authorUserId: actorUserId, authorOrganizationId: clinicOrganizationId, authorSide: LabCaseCommentSide.CLINIC, body: dto.body.trim(), visibleToClinic: true, }, include: commentInclude, }); await this.labCaseActivity.record({ labCaseId: caseId, type: LabCaseActivityType.CLINIC_COMMENT, actorUserId, payload: { commentId: created.id }, }); return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) }; } async countForCase(caseId: string) { const count = await this.prisma.labCaseComment.count({ where: { labCaseId: caseId } }); return { success: true, data: { count } }; } // ---------- Helpers ---------- private fetchCommentsForClinicViewer(caseId: string) { return this.prisma.labCaseComment.findMany({ where: { labCaseId: caseId, OR: [{ visibleToClinic: true }, { authorSide: LabCaseCommentSide.CLINIC }], }, include: commentInclude, orderBy: { createdAt: 'asc' }, }); } 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) { const showVisibilityStatus = viewerSide === LabCaseCommentSide.LAB; 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(), canToggleVisibility: viewerSide === LabCaseCommentSide.LAB && comment.authorSide === LabCaseCommentSide.LAB, showVisibilityStatus, }; } 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, OR: [{ isOwner: true }, { isActive: true }], }, include: { permissions: { include: { permission: true } }, organization: { include: { type: true, plan: true } }, }, }); if (!membership) { throw new ForbiddenException('You are not a member of this organization'); } if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) { throw new ForbiddenException('You do not have access to task comments'); } } private async assertClinicOwnsCase( caseId: string, clinicOrganizationId: string, opts?: { requireSent?: boolean }, ) { const labCase = await this.prisma.labCase.findFirst({ where: { id: caseId, ...(opts?.requireSent ? { sentAt: { not: null } } : {}), treatment: { organizationId: clinicOrganizationId }, }, select: { id: true }, }); if (!labCase) { throw new NotFoundException('Case not found'); } } private async assertClinicTreatmentAccess( caseId: string, clinicOrganizationId: string, actorUserId: string, ) { await this.assertClinicOwnsCase(caseId, clinicOrganizationId); const membership = await this.prisma.membership.findFirst({ where: { userId: actorUserId, organizationId: clinicOrganizationId, OR: [{ isOwner: true }, { isActive: true }], }, include: { permissions: { include: { permission: true } }, organization: { include: { type: true, plan: true } }, }, }); if (!membership) { throw new ForbiddenException('You are not a member of this organization'); } if ( hasEffectivePermission(membership, 'TAB_TREATMENT_READ') || hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT') ) { return; } throw new ForbiddenException('You do not have access to treatment cases'); } }