406 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|