Files
dyolink/backend/src/modules/treatments/treatments.service.ts

984 lines
30 KiB
TypeScript
Raw Normal View History

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabTaskStatus, LinkStatus } from '@prisma/client';
import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import {
SaveTreatmentDraftDto,
SaveTreatmentLabCasesDto,
} from './dto/treatment.dto';
import {
generateTreatmentTitle,
normalizeTeeth,
} from './treatment.utils';
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
import { hasEffectivePermission } from '../../common/membership-permissions';
const treatmentInclude = {
details: {
orderBy: [{ sortOrder: 'asc' as const }],
include: {
attachments: { orderBy: [{ createdAt: 'asc' as const }] },
labCaseLink: {
include: {
labCase: {
include: {
sends: {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
tasks: { select: { id: true, status: true } },
},
},
},
},
},
},
labCases: {
orderBy: [{ sortOrder: 'asc' as const }],
include: {
details: {
include: {
detail: {
select: { id: true, clientKey: true, treatmentType: true, teeth: true },
},
},
},
sends: {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
toothProsthesis: true,
attachments: {
include: {
attachment: {
select: { id: true, fileName: true, mimeType: true, sizeBytes: true, createdAt: true },
},
},
},
tasks: { select: { id: true, status: true } },
},
},
};
@Injectable()
export class TreatmentsService {
private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments');
constructor(
private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService,
private readonly prosthesisCatalog: ProsthesisCatalogService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async listLinkedOrganizations(userId: string, organizationId: string) {
await this.assertCanReadTreatment(userId, organizationId);
const [linksA, linksB] = await Promise.all([
this.prisma.organizationLink.findMany({
where: { organizationAId: organizationId, status: LinkStatus.ACTIVE },
include: { organizationB: { select: { id: true, name: true } } },
}),
this.prisma.organizationLink.findMany({
where: { organizationBId: organizationId, status: LinkStatus.ACTIVE },
include: { organizationA: { select: { id: true, name: true } } },
}),
]);
const data = [
...linksA.map((l) => ({
id: l.organizationB.id,
name: l.organizationB.name,
active: true,
})),
...linksB.map((l) => ({
id: l.organizationA.id,
name: l.organizationA.name,
active: true,
})),
].sort((a, b) => a.name.localeCompare(b.name));
return { success: true, data };
}
async listPatientHistory(
patientId: string,
organizationId: string,
actorUserId: string,
limit = 20,
) {
await this.assertCanReadTreatment(actorUserId, organizationId);
await this.ensurePatientExists(patientId);
const membership = await this.getMembership(actorUserId, organizationId);
const isOwner = membership?.isOwner ?? false;
const items = await this.prisma.treatment.findMany({
where: {
patientId,
organizationId,
details: { some: {} },
...(isOwner
? {}
: {
OR: [
{ providerUserId: actorUserId },
{ appointment: { is: { providerUserId: actorUserId } } },
],
}),
},
include: treatmentInclude,
orderBy: [{ treatmentAt: 'desc' }, { createdAt: 'desc' }],
take: Math.min(Math.max(limit, 1), 100),
});
return { success: true, data: items.map((t) => this.mapTreatment(t)) };
}
async getDraftForAppointment(
appointmentId: string,
organizationId: string,
actorUserId: string,
) {
await this.assertCanReadTreatment(actorUserId, organizationId);
const appointment = await this.ensureAppointmentProvider(
appointmentId,
organizationId,
actorUserId,
false,
);
const treatment = await this.prisma.treatment.findFirst({
where: {
appointmentId: appointment.id,
organizationId,
},
include: treatmentInclude,
});
return { success: true, data: treatment ? this.mapTreatment(treatment) : null };
}
async saveDraftForAppointment(
appointmentId: string,
dto: SaveTreatmentDraftDto,
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const appointment = await this.ensureAppointmentProvider(
appointmentId,
organizationId,
actorUserId,
true,
);
for (const d of dto.details) {
this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType);
}
const normalizedDetails = dto.details.map((d, index) => ({
...d,
sortOrder: index,
teeth: normalizeTeeth(d.teeth),
comment: d.comment?.trim() || null,
attachmentIds: d.attachmentIds ?? [],
}));
const title = generateTreatmentTitle(
normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })),
);
const treatment = await this.prisma.$transaction(async (tx) => {
const existing = await tx.treatment.findFirst({
where: { appointmentId: appointment.id, organizationId },
select: { id: true },
});
const saved = existing
? await tx.treatment.update({
where: { id: existing.id },
data: {
title,
treatmentAt: appointment.startAt,
patientId: appointment.patientId,
providerUserId: actorUserId,
},
})
: await tx.treatment.create({
data: {
organizationId,
patientId: appointment.patientId,
appointmentId: appointment.id,
providerUserId: actorUserId,
title,
treatmentAt: appointment.startAt,
},
});
const keepDetailIds = normalizedDetails.map((d) => d.id).filter(Boolean) as string[];
const existingDetails = existing
? await tx.treatmentDetail.findMany({
where: { treatmentId: saved.id },
select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } },
})
: [];
const lockedDetailIds = new Set(
existingDetails
.filter((d) => d.labCaseLink?.labCase.sentAt)
.map((d) => d.id),
);
const removableDetailIds = existingDetails
.filter((d) => !keepDetailIds.includes(d.id) && !lockedDetailIds.has(d.id))
.map((d) => d.id);
if (removableDetailIds.length > 0) {
await tx.treatmentDetail.deleteMany({
where: { id: { in: removableDetailIds }, treatmentId: saved.id },
});
}
for (const d of normalizedDetails) {
if (d.id && lockedDetailIds.has(d.id)) {
continue;
}
const row = d.id
? await tx.treatmentDetail.update({
where: { id: d.id },
data: {
clientKey: d.clientId,
sortOrder: d.sortOrder,
treatmentType: d.treatmentType,
teeth: d.teeth,
comment: d.comment,
},
})
: await tx.treatmentDetail.create({
data: {
treatmentId: saved.id,
clientKey: d.clientId,
sortOrder: d.sortOrder,
treatmentType: d.treatmentType,
teeth: d.teeth,
comment: d.comment,
},
});
const allowedAttachmentIds = new Set(d.attachmentIds);
const pendingAttachments = await tx.treatmentDetailAttachment.findMany({
where: {
appointmentId: appointment.id,
detailClientKey: d.clientId,
},
});
for (const attachment of pendingAttachments) {
if (!allowedAttachmentIds.has(attachment.id)) {
await tx.treatmentDetailAttachment.delete({ where: { id: attachment.id } });
} else {
await tx.treatmentDetailAttachment.update({
where: { id: attachment.id },
data: { detailId: row.id, appointmentId: null, detailClientKey: null },
});
}
}
await tx.treatmentDetailAttachment.deleteMany({
where: {
detailId: row.id,
id: { notIn: [...allowedAttachmentIds] },
},
});
}
return tx.treatment.findUniqueOrThrow({
where: { id: saved.id },
include: treatmentInclude,
});
});
return { success: true, data: this.mapTreatment(treatment) };
}
async saveLabCasesForAppointment(
appointmentId: string,
dto: SaveTreatmentLabCasesDto,
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const appointment = await this.ensureAppointmentProvider(
appointmentId,
organizationId,
actorUserId,
true,
);
const treatment = await this.prisma.treatment.findFirst({
where: { appointmentId: appointment.id, organizationId },
select: { id: true },
});
if (!treatment) {
throw new NotFoundException('Save treatment details before creating lab cases');
}
const detailIds = dto.labCases.map((lc) => lc.treatmentDetailId);
const uniqueDetailIds = new Set(detailIds);
if (uniqueDetailIds.size !== detailIds.length) {
throw new BadRequestException('Each treatment detail can belong to only one lab case');
}
const details = await this.prisma.treatmentDetail.findMany({
where: { treatmentId: treatment.id, id: { in: detailIds } },
select: { id: true, treatmentType: true, teeth: true },
});
if (details.length !== uniqueDetailIds.size) {
throw new BadRequestException('One or more treatment details were not found');
}
for (const detail of details) {
this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType);
}
const detailById = new Map(details.map((d) => [d.id, d]));
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
for (const lc of dto.labCases) {
if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) {
throw new BadRequestException('Destination organization is not an active linked counterpart');
}
for (const row of lc.toothProsthesis ?? []) {
if (lc.treatmentDetailId !== row.treatmentDetailId) {
throw new BadRequestException(
'Tooth prosthesis must reference the lab case treatment detail',
);
}
const detail = detailById.get(row.treatmentDetailId);
if (!detail) {
throw new BadRequestException('Tooth prosthesis references an unknown treatment detail');
}
const teeth = normalizeTeeth(detail.teeth);
if (!teeth.includes(row.tooth)) {
throw new BadRequestException(`Tooth ${row.tooth} is not on the selected treatment detail`);
}
this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode);
}
}
const saved = await this.prisma.$transaction(async (tx) => {
const existingLabCases = await tx.labCase.findMany({
where: { treatmentId: treatment.id },
select: { id: true, sentAt: true },
});
const sentLabCaseIds = new Set(existingLabCases.filter((lc) => lc.sentAt).map((lc) => lc.id));
const keepLabCaseIds = dto.labCases.map((lc) => lc.id).filter(Boolean) as string[];
const removableLabCaseIds = existingLabCases
.filter((lc) => !keepLabCaseIds.includes(lc.id) && !lc.sentAt)
.map((lc) => lc.id);
if (removableLabCaseIds.length > 0) {
await tx.labCase.deleteMany({
where: { id: { in: removableLabCaseIds }, treatmentId: treatment.id },
});
}
for (const [index, lc] of dto.labCases.entries()) {
if (lc.id && sentLabCaseIds.has(lc.id)) {
continue;
}
const row = lc.id
? await tx.labCase.update({
where: { id: lc.id },
data: {
clientKey: lc.clientId,
sortOrder: index,
destinationOrganizationId: lc.destinationOrganizationId ?? null,
},
})
: await tx.labCase.create({
data: {
treatmentId: treatment.id,
clientKey: lc.clientId,
sortOrder: index,
destinationOrganizationId: lc.destinationOrganizationId ?? null,
},
});
await tx.labCaseDetail.deleteMany({ where: { labCaseId: row.id } });
await tx.labCaseDetail.create({
data: {
labCaseId: row.id,
treatmentDetailId: lc.treatmentDetailId,
},
});
await tx.labCaseToothProsthesis.deleteMany({ where: { labCaseId: row.id } });
if (lc.toothProsthesis?.length) {
await tx.labCaseToothProsthesis.createMany({
data: lc.toothProsthesis.map((tp) => ({
labCaseId: row.id,
treatmentDetailId: tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
});
}
await tx.labCaseAttachment.deleteMany({ where: { labCaseId: row.id } });
const attachmentIds = lc.attachmentIds ?? [];
if (attachmentIds.length > 0) {
const validAttachments = await tx.treatmentDetailAttachment.findMany({
where: {
id: { in: attachmentIds },
detailId: lc.treatmentDetailId,
},
select: { id: true },
});
if (validAttachments.length !== attachmentIds.length) {
throw new BadRequestException(
'One or more attachments are invalid for this lab case',
);
}
await tx.labCaseAttachment.createMany({
data: attachmentIds.map((attachmentId) => ({
labCaseId: row.id,
attachmentId,
})),
});
}
}
return tx.treatment.findUniqueOrThrow({
where: { id: treatment.id },
include: treatmentInclude,
});
});
return { success: true, data: this.mapTreatment(saved) };
}
async sendLabCase(
labCaseId: string,
organizationId: string,
actorUserId: string,
actorLanguage?: string | null,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const labCase = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
treatment: { organizationId },
},
include: {
treatment: { select: { providerUserId: true } },
sends: { select: { organizationId: true } },
details: {
include: {
detail: { select: { id: true, treatmentType: true, teeth: true } },
},
},
toothProsthesis: true,
},
});
if (!labCase) {
throw new NotFoundException('Lab case not found');
}
if (!labCase.destinationOrganizationId) {
throw new BadRequestException('Lab case has no destination organization');
}
if (labCase.details.length === 0) {
throw new BadRequestException('Lab case must include a treatment detail');
}
if (labCase.details.length > 1) {
throw new BadRequestException('Lab case can include only one treatment detail');
}
assertCompleteToothProsthesisMap(labCase);
if (labCase.treatment.providerUserId !== actorUserId) {
const membership = await this.getMembership(actorUserId, organizationId);
if (!membership?.isOwner) {
throw new ForbiddenException('Only the appointment provider can send this lab case');
}
}
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
if (!linkedOrgIds.has(labCase.destinationOrganizationId)) {
throw new BadRequestException('Destination organization is not an active linked counterpart');
}
const alreadySent = labCase.sends.some(
(s) => s.organizationId === labCase.destinationOrganizationId,
);
if (alreadySent) {
throw new BadRequestException('Lab case was already sent to the destination organization');
}
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.labCaseSend.create({
data: {
labCaseId,
organizationId: labCase.destinationOrganizationId!,
},
});
if (!labCase.sentAt) {
await tx.labCase.update({
where: { id: labCaseId },
data: { sentAt: now },
});
}
await generateLabCaseTasks(tx, labCaseId, actorLanguage);
});
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
where: { id: labCaseId },
include: {
details: {
include: {
detail: {
select: { id: true, clientKey: true, treatmentType: true, teeth: true },
},
},
},
sends: {
orderBy: [{ sentAt: 'asc' }],
include: { organization: { select: { id: true, name: true } } },
},
toothProsthesis: true,
tasks: { select: { id: true, status: true } },
},
});
return { success: true, data: this.mapLabCase(refreshed) };
}
async uploadDetailAttachments(
appointmentId: string,
detailClientKey: string,
files: Express.Multer.File[],
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true);
if (!detailClientKey?.trim()) {
throw new BadRequestException('detailClientKey is required');
}
if (!files?.length) {
throw new BadRequestException('At least one file is required');
}
const orgDir = join(this.uploadRoot, organizationId);
mkdirSync(orgDir, { recursive: true });
const created: {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}[] = [];
for (const file of files) {
const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`;
const storagePath = join(orgDir, storageName);
const { writeFileSync } = await import('fs');
writeFileSync(storagePath, file.buffer);
const attachment = await this.prisma.treatmentDetailAttachment.create({
data: {
appointmentId,
detailClientKey,
fileName: file.originalname,
mimeType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size,
storagePath,
},
});
created.push(this.mapAttachment(attachment));
}
return { success: true, data: created };
}
async streamAttachmentFile(
attachmentId: string,
organizationId: string,
actorUserId: string,
) {
await this.assertCanReadTreatment(actorUserId, organizationId);
const attachment = await this.prisma.treatmentDetailAttachment.findFirst({
where: {
id: attachmentId,
OR: [
{ detail: { treatment: { organizationId } } },
{ appointmentId: { not: null } },
],
},
include: {
detail: { select: { treatment: { select: { organizationId: true } } } },
},
});
if (!attachment) {
throw new NotFoundException('Attachment not found');
}
if (attachment.detail && attachment.detail.treatment.organizationId !== organizationId) {
throw new NotFoundException('Attachment not found');
}
if (!attachment.detail && attachment.appointmentId) {
const appointment = await this.prisma.appointment.findFirst({
where: { id: attachment.appointmentId, organizationId },
select: { id: true },
});
if (!appointment) {
throw new NotFoundException('Attachment not found');
}
}
if (!existsSync(attachment.storagePath)) {
throw new NotFoundException('File is no longer available');
}
return {
stream: createReadStream(attachment.storagePath),
fileName: attachment.fileName,
mimeType: attachment.mimeType,
};
}
private mapTreatment(treatment: {
id: string;
patientId: string;
appointmentId: string | null;
title: string;
treatmentAt: Date;
details: Array<{
id: string;
clientKey: string | null;
treatmentType: string;
teeth: unknown;
comment: string | null;
attachments: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}>;
labCaseLink?: {
labCase: {
id: string;
sentAt: Date | null;
destinationOrganizationId: string | null;
sends: Array<{
organizationId: string;
sentAt: Date;
organization: { id: string; name: string };
}>;
};
} | null;
}>;
labCases: Array<{
id: string;
clientKey: string | null;
sortOrder: number;
destinationOrganizationId: string | null;
sentAt: Date | null;
details: Array<{
treatmentDetailId: string;
detail: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown };
}>;
sends: Array<{
organizationId: string;
sentAt: Date;
organization: { id: string; name: string };
}>;
}>;
}) {
const documents = treatment.details.flatMap((d) =>
d.attachments.map((a) => this.mapAttachment(a)),
);
return {
id: treatment.id,
patientId: treatment.patientId,
appointmentId: treatment.appointmentId,
title: treatment.title,
treatmentAt: treatment.treatmentAt.toISOString(),
details: treatment.details.map((d) => this.mapDetail(d)),
labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)),
documents,
};
}
private mapDetail(d: {
id: string;
clientKey?: string | null;
treatmentType: string;
teeth: unknown;
comment?: string | null;
attachments?: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}>;
labCaseLink?: {
labCase: {
id: string;
sentAt: Date | null;
destinationOrganizationId: string | null;
sends: Array<{
organizationId: string;
sentAt: Date;
organization?: { id: string; name: string };
}>;
tasks?: Array<{ id: string; status: LabTaskStatus }>;
};
} | null;
}) {
const labCase = d.labCaseLink?.labCase;
const taskProgress = this.mapTaskProgress(labCase?.tasks ?? []);
return {
id: d.id,
clientId: d.clientKey ?? d.id,
treatmentType: d.treatmentType,
teeth: normalizeTeeth(d.teeth),
notes: d.comment ?? null,
attachmentMetas: (d.attachments ?? []).map((a) => this.mapAttachment(a)),
labCaseId: labCase?.id ?? null,
sentAt: labCase?.sentAt?.toISOString() ?? null,
destinationOrganizationId: labCase?.destinationOrganizationId ?? null,
taskProgress,
sends:
labCase?.sends.map((s) => ({
organizationId: s.organizationId,
organizationName: s.organization?.name ?? 'Unknown organization',
sentAt: s.sentAt.toISOString(),
})) ?? [],
};
}
private mapLabCase(lc: {
id: string;
clientKey?: string | null;
sortOrder?: number;
destinationOrganizationId?: string | null;
sentAt?: Date | null;
details?: Array<{
treatmentDetailId: string;
detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown };
}>;
sends?: Array<{
organizationId: string;
sentAt: Date;
organization?: { id: string; name: string };
}>;
toothProsthesis?: Array<{
treatmentDetailId: string;
tooth: string;
prosthesisTypeCode: string;
}>;
attachments?: Array<{
attachment: {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
};
}>;
}) {
return {
id: lc.id,
clientId: lc.clientKey ?? lc.id,
destinationOrganizationId: lc.destinationOrganizationId ?? null,
sentAt: lc.sentAt?.toISOString() ?? null,
treatmentDetailId: lc.details?.[0]?.treatmentDetailId ?? null,
detail: lc.details?.[0]
? {
id: lc.details[0].detail?.id ?? lc.details[0].treatmentDetailId,
clientId: lc.details[0].detail?.clientKey ?? lc.details[0].treatmentDetailId,
treatmentType: lc.details[0].detail?.treatmentType ?? '',
teeth: lc.details[0].detail ? normalizeTeeth(lc.details[0].detail.teeth) : [],
}
: null,
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
treatmentDetailId: tp.treatmentDetailId,
tooth: tp.tooth,
prosthesisTypeCode: tp.prosthesisTypeCode,
})),
attachments: (lc.attachments ?? []).map((row) => ({
id: row.attachment.id,
fileName: row.attachment.fileName,
mimeType: row.attachment.mimeType,
sizeBytes: row.attachment.sizeBytes,
createdAt: row.attachment.createdAt.toISOString(),
})),
sends:
lc.sends?.map((s) => ({
organizationId: s.organizationId,
organizationName: s.organization?.name ?? 'Unknown organization',
sentAt: s.sentAt.toISOString(),
})) ?? [],
};
}
private mapTaskProgress(tasks: Array<{ status: LabTaskStatus }>) {
const total = tasks.length;
const completed = tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length;
return { completed, total };
}
private mapAttachment(a: {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}) {
return {
id: a.id,
fileName: a.fileName,
mimeType: a.mimeType,
sizeBytes: a.sizeBytes,
};
}
private async getActiveLinkedOrganizationIds(organizationId: string) {
const [linksA, linksB] = await Promise.all([
this.prisma.organizationLink.findMany({
where: { organizationAId: organizationId, status: LinkStatus.ACTIVE },
select: { organizationBId: true },
}),
this.prisma.organizationLink.findMany({
where: { organizationBId: organizationId, status: LinkStatus.ACTIVE },
select: { organizationAId: true },
}),
]);
return new Set([
...linksA.map((l) => l.organizationBId),
...linksB.map((l) => l.organizationAId),
]);
}
private async ensurePatientExists(patientId: string) {
const patient = await this.prisma.patient.findUnique({
where: { id: patientId },
select: { id: true },
});
if (!patient) {
throw new NotFoundException('Patient not found');
}
}
private async ensureAppointmentProvider(
appointmentId: string,
organizationId: string,
actorUserId: string,
requireProviderMatch: boolean,
) {
const appointment = await this.prisma.appointment.findFirst({
where: { id: appointmentId, organizationId },
select: {
id: true,
patientId: true,
providerUserId: true,
startAt: true,
},
});
if (!appointment) {
throw new NotFoundException('Appointment not found');
}
if (requireProviderMatch) {
const membership = await this.getMembership(actorUserId, organizationId);
const isOwner = membership?.isOwner ?? false;
if (!isOwner && appointment.providerUserId !== actorUserId) {
throw new ForbiddenException('You are not the provider for this appointment');
}
}
return appointment;
}
private async assertCanReadTreatment(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
}
if (
hasEffectivePermission(m, 'TAB_TREATMENT_READ') ||
hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')
) {
return;
}
throw new ForbiddenException('You do not have access to treatments');
}
private async assertCanEditTreatment(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
}
if (hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
return;
}
throw new ForbiddenException('You cannot edit treatments');
}
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
}
}