import { ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; import { UserNotificationService } from '../notifications/user-notification.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, private readonly userNotifications: UserNotificationService, ) {} // ---------- 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 listForLabViewer(caseId: string, labOrganizationId: string, actorUserId: string) { await this.assertLabCanViewCase(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, }, }); void this.userNotifications.notify({ organizationId: labOrganizationId, type: UserNotificationType.LAB_COMMENT, href: `/cases?caseId=${encodeURIComponent(caseId)}`, actorUserId, payload: { labCaseId: caseId, commentId: created.id }, requiredPermission: 'TAB_TASKS_READ', }); if (created.visibleToClinic) { const clinicOrgId = await this.clinicOrgIdForCase(caseId); if (clinicOrgId) { void this.userNotifications.notify({ organizationId: clinicOrgId, type: UserNotificationType.LAB_COMMENT_CLINIC, href: `/treatment?labCaseId=${encodeURIComponent(caseId)}`, actorUserId, payload: { labCaseId: caseId, commentId: created.id }, requiredPermission: 'TAB_TREATMENT_READ', labCaseIdForProviderScope: caseId, }); } } 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 }, }); const labOrgId = await this.labOrgIdForCase(caseId); if (labOrgId) { void this.userNotifications.notify({ organizationId: labOrgId, type: UserNotificationType.CLINIC_COMMENT, href: `/cases?caseId=${encodeURIComponent(caseId)}`, actorUserId, payload: { labCaseId: caseId, commentId: created.id }, requiredPermission: 'TAB_CASES_READ', }); } 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 }, }); const labOrgId = await this.labOrgIdForCase(caseId); if (labOrgId) { void this.userNotifications.notify({ organizationId: labOrgId, type: UserNotificationType.CLINIC_COMMENT, href: `/cases?caseId=${encodeURIComponent(caseId)}`, actorUserId, payload: { labCaseId: caseId, commentId: created.id }, requiredPermission: 'TAB_CASES_READ', }); } 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, ) { await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId); const membership = await this.getLabMembership(actorUserId, labOrganizationId); if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) { throw new ForbiddenException('You do not have access to task comments'); } } private async assertLabCanViewCase( 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.getLabMembership(actorUserId, labOrganizationId); if ( !hasEffectivePermission(membership, 'TAB_TASKS_READ') && !hasEffectivePermission(membership, 'TAB_TASKS_EDIT') ) { throw new ForbiddenException('You do not have access to tasks'); } } private async getLabMembership(userId: string, organizationId: string) { const membership = await this.prisma.membership.findFirst({ where: { userId, organizationId, 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'); } return membership; } 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, ) { const labCase = await this.prisma.labCase.findFirst({ where: { id: caseId, treatment: { organizationId: clinicOrganizationId, ...treatmentProviderScopeWhere(actorUserId), }, }, select: { id: true }, }); if (!labCase) { throw new NotFoundException('Case not found'); } 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'); } private async labOrgIdForCase(caseId: string): Promise { const send = await this.prisma.labCaseSend.findFirst({ where: { labCaseId: caseId }, orderBy: { sentAt: 'asc' }, select: { organizationId: true }, }); return send?.organizationId ?? null; } private async clinicOrgIdForCase(caseId: string): Promise { const labCase = await this.prisma.labCase.findUnique({ where: { id: caseId }, select: { treatment: { select: { organizationId: true } } }, }); return labCase?.treatment.organizationId ?? null; } }