Files
dyolink/backend/src/modules/lab-case-comments/lab-case-comments.service.ts

406 lines
13 KiB
TypeScript

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<string | null> {
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<string | null> {
const labCase = await this.prisma.labCase.findUnique({
where: { id: caseId },
select: { treatment: { select: { organizationId: true } } },
});
return labCase?.treatment.organizationId ?? null;
}
}