2026-05-19 22:29:29 +03:30
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
ForbiddenException,
|
|
|
|
|
Injectable,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
2026-06-28 21:45:33 +03:30
|
|
|
import { LinkStatus } from '@prisma/client';
|
2026-05-19 22:29:29 +03:30
|
|
|
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
|
|
|
|
import { join } from 'path';
|
|
|
|
|
import { randomUUID } from 'crypto';
|
|
|
|
|
import { PrismaService } from '../../../prisma/prisma.service';
|
2026-06-28 16:46:42 +03:30
|
|
|
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
|
2026-07-06 20:40:19 +03:30
|
|
|
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
2026-06-28 16:46:42 +03:30
|
|
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
2026-06-28 15:34:56 +03:30
|
|
|
import {
|
|
|
|
|
SaveTreatmentDraftDto,
|
|
|
|
|
SaveTreatmentLabCasesDto,
|
|
|
|
|
} from './dto/treatment.dto';
|
2026-05-19 22:29:29 +03:30
|
|
|
import {
|
|
|
|
|
generateTreatmentTitle,
|
|
|
|
|
normalizeTeeth,
|
|
|
|
|
} from './treatment.utils';
|
2026-07-06 20:40:19 +03:30
|
|
|
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
|
2026-05-19 22:29:29 +03:30
|
|
|
|
|
|
|
|
const treatmentInclude = {
|
2026-06-28 15:34:56 +03:30
|
|
|
details: {
|
2026-05-19 22:29:29 +03:30
|
|
|
orderBy: [{ sortOrder: 'asc' as const }],
|
|
|
|
|
include: {
|
|
|
|
|
attachments: { orderBy: [{ createdAt: 'asc' as const }] },
|
2026-06-28 15:34:56 +03:30
|
|
|
labCaseLink: {
|
|
|
|
|
include: {
|
|
|
|
|
labCase: {
|
|
|
|
|
include: {
|
|
|
|
|
sends: {
|
|
|
|
|
orderBy: [{ sentAt: 'asc' as const }],
|
|
|
|
|
include: { organization: { select: { id: true, name: true } } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
labCases: {
|
|
|
|
|
orderBy: [{ sortOrder: 'asc' as const }],
|
|
|
|
|
include: {
|
|
|
|
|
details: {
|
|
|
|
|
include: {
|
|
|
|
|
detail: {
|
|
|
|
|
select: { id: true, clientKey: true, treatmentType: true, teeth: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-19 23:43:43 +03:30
|
|
|
sends: {
|
|
|
|
|
orderBy: [{ sentAt: 'asc' as const }],
|
|
|
|
|
include: { organization: { select: { id: true, name: true } } },
|
|
|
|
|
},
|
2026-07-06 20:40:19 +03:30
|
|
|
toothProsthesis: true,
|
2026-05-19 22:29:29 +03:30
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class TreatmentsService {
|
|
|
|
|
private readonly uploadRoot = join(process.cwd(), 'uploads', 'treatments');
|
|
|
|
|
|
2026-06-28 16:46:42 +03:30
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
private readonly treatmentCatalog: TreatmentCatalogService,
|
2026-07-06 20:40:19 +03:30
|
|
|
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
2026-06-28 16:46:42 +03:30
|
|
|
) {}
|
2026-05-19 22:29:29 +03:30
|
|
|
|
|
|
|
|
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);
|
2026-06-28 15:34:56 +03:30
|
|
|
await this.ensurePatientExists(patientId);
|
2026-05-19 22:29:29 +03:30
|
|
|
|
|
|
|
|
const items = await this.prisma.treatment.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
patientId,
|
|
|
|
|
organizationId,
|
2026-06-28 21:45:33 +03:30
|
|
|
details: { some: {} },
|
2026-05-19 22:29:29 +03:30
|
|
|
},
|
|
|
|
|
include: treatmentInclude,
|
|
|
|
|
orderBy: [{ treatmentAt: '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,
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
for (const d of dto.details) {
|
2026-06-28 16:46:42 +03:30
|
|
|
this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType);
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
const normalizedDetails = dto.details.map((d, index) => ({
|
|
|
|
|
...d,
|
2026-05-19 22:29:29 +03:30
|
|
|
sortOrder: index,
|
2026-06-28 15:34:56 +03:30
|
|
|
teeth: normalizeTeeth(d.teeth),
|
|
|
|
|
comment: d.comment?.trim() || null,
|
|
|
|
|
attachmentIds: d.attachmentIds ?? [],
|
2026-05-19 22:29:29 +03:30
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const title = generateTreatmentTitle(
|
2026-06-28 15:34:56 +03:30
|
|
|
normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })),
|
2026-05-19 22:29:29 +03:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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: appointment.providerUserId,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
: await tx.treatment.create({
|
|
|
|
|
data: {
|
|
|
|
|
organizationId,
|
|
|
|
|
patientId: appointment.patientId,
|
|
|
|
|
appointmentId: appointment.id,
|
|
|
|
|
providerUserId: appointment.providerUserId,
|
|
|
|
|
title,
|
|
|
|
|
treatmentAt: appointment.startAt,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
const keepDetailIds = normalizedDetails.map((d) => d.id).filter(Boolean) as string[];
|
|
|
|
|
|
|
|
|
|
const existingDetails = existing
|
|
|
|
|
? await tx.treatmentDetail.findMany({
|
2026-05-19 22:29:29 +03:30
|
|
|
where: { treatmentId: saved.id },
|
2026-06-28 15:34:56 +03:30
|
|
|
select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } },
|
2026-05-19 22:29:29 +03:30
|
|
|
})
|
|
|
|
|
: [];
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
const lockedDetailIds = new Set(
|
|
|
|
|
existingDetails
|
|
|
|
|
.filter((d) => d.labCaseLink?.labCase.sentAt)
|
|
|
|
|
.map((d) => d.id),
|
2026-05-19 22:29:29 +03:30
|
|
|
);
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
const removableDetailIds = existingDetails
|
|
|
|
|
.filter((d) => !keepDetailIds.includes(d.id) && !lockedDetailIds.has(d.id))
|
|
|
|
|
.map((d) => d.id);
|
2026-05-19 22:29:29 +03:30
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
if (removableDetailIds.length > 0) {
|
|
|
|
|
await tx.treatmentDetail.deleteMany({
|
|
|
|
|
where: { id: { in: removableDetailIds }, treatmentId: saved.id },
|
2026-05-19 22:29:29 +03:30
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
for (const d of normalizedDetails) {
|
|
|
|
|
if (d.id && lockedDetailIds.has(d.id)) {
|
2026-05-19 22:29:29 +03:30
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
const row = d.id
|
|
|
|
|
? await tx.treatmentDetail.update({
|
|
|
|
|
where: { id: d.id },
|
2026-05-19 22:29:29 +03:30
|
|
|
data: {
|
2026-06-28 15:34:56 +03:30
|
|
|
clientKey: d.clientId,
|
|
|
|
|
sortOrder: d.sortOrder,
|
|
|
|
|
treatmentType: d.treatmentType,
|
|
|
|
|
teeth: d.teeth,
|
|
|
|
|
comment: d.comment,
|
2026-05-19 22:29:29 +03:30
|
|
|
},
|
|
|
|
|
})
|
2026-06-28 15:34:56 +03:30
|
|
|
: await tx.treatmentDetail.create({
|
2026-05-19 22:29:29 +03:30
|
|
|
data: {
|
|
|
|
|
treatmentId: saved.id,
|
2026-06-28 15:34:56 +03:30
|
|
|
clientKey: d.clientId,
|
|
|
|
|
sortOrder: d.sortOrder,
|
|
|
|
|
treatmentType: d.treatmentType,
|
|
|
|
|
teeth: d.teeth,
|
|
|
|
|
comment: d.comment,
|
2026-05-19 22:29:29 +03:30
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
const allowedAttachmentIds = new Set(d.attachmentIds);
|
|
|
|
|
const pendingAttachments = await tx.treatmentDetailAttachment.findMany({
|
2026-05-19 22:29:29 +03:30
|
|
|
where: {
|
|
|
|
|
appointmentId: appointment.id,
|
2026-06-28 15:34:56 +03:30
|
|
|
detailClientKey: d.clientId,
|
2026-05-19 22:29:29 +03:30
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const attachment of pendingAttachments) {
|
|
|
|
|
if (!allowedAttachmentIds.has(attachment.id)) {
|
2026-06-28 15:34:56 +03:30
|
|
|
await tx.treatmentDetailAttachment.delete({ where: { id: attachment.id } });
|
2026-05-19 22:29:29 +03:30
|
|
|
} else {
|
2026-06-28 15:34:56 +03:30
|
|
|
await tx.treatmentDetailAttachment.update({
|
2026-05-19 22:29:29 +03:30
|
|
|
where: { id: attachment.id },
|
2026-06-28 15:34:56 +03:30
|
|
|
data: { detailId: row.id, appointmentId: null, detailClientKey: null },
|
2026-05-19 22:29:29 +03:30
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
await tx.treatmentDetailAttachment.deleteMany({
|
2026-05-19 22:29:29 +03:30
|
|
|
where: {
|
2026-06-28 15:34:56 +03:30
|
|
|
detailId: row.id,
|
2026-05-19 22:29:29 +03:30
|
|
|
id: { notIn: [...allowedAttachmentIds] },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tx.treatment.findUniqueOrThrow({
|
|
|
|
|
where: { id: saved.id },
|
|
|
|
|
include: treatmentInclude,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { success: true, data: this.mapTreatment(treatment) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
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({
|
2026-06-28 21:45:33 +03:30
|
|
|
where: { appointmentId: appointment.id, organizationId },
|
2026-06-28 15:34:56 +03:30
|
|
|
select: { id: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!treatment) {
|
|
|
|
|
throw new NotFoundException('Save treatment details before creating lab cases');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const detailIds = dto.labCases.flatMap((lc) => lc.treatmentDetailIds);
|
|
|
|
|
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 } },
|
2026-07-06 20:40:19 +03:30
|
|
|
select: { id: true, treatmentType: true, teeth: true },
|
2026-06-28 15:34:56 +03:30
|
|
|
});
|
|
|
|
|
if (details.length !== uniqueDetailIds.size) {
|
|
|
|
|
throw new BadRequestException('One or more treatment details were not found');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 16:46:42 +03:30
|
|
|
for (const detail of details) {
|
|
|
|
|
this.treatmentCatalog.assertLabDependentTreatmentType(detail.treatmentType);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
const detailById = new Map(details.map((d) => [d.id, d]));
|
2026-06-28 15:34:56 +03:30
|
|
|
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');
|
|
|
|
|
}
|
2026-07-06 20:40:19 +03:30
|
|
|
|
|
|
|
|
for (const row of lc.toothProsthesis ?? []) {
|
|
|
|
|
if (!lc.treatmentDetailIds.includes(row.treatmentDetailId)) {
|
|
|
|
|
throw new BadRequestException(
|
|
|
|
|
'Tooth prosthesis must reference a detail included in this lab case',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-06-28 15:34:56 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.createMany({
|
|
|
|
|
data: lc.treatmentDetailIds.map((treatmentDetailId) => ({
|
|
|
|
|
labCaseId: row.id,
|
|
|
|
|
treatmentDetailId,
|
|
|
|
|
})),
|
|
|
|
|
});
|
2026-07-06 20:40:19 +03:30
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-28 15:34:56 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tx.treatment.findUniqueOrThrow({
|
|
|
|
|
where: { id: treatment.id },
|
|
|
|
|
include: treatmentInclude,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { success: true, data: this.mapTreatment(saved) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async sendLabCase(
|
|
|
|
|
labCaseId: string,
|
2026-05-19 22:29:29 +03:30
|
|
|
organizationId: string,
|
|
|
|
|
actorUserId: string,
|
2026-07-06 20:40:19 +03:30
|
|
|
actorLanguage?: string | null,
|
2026-05-19 22:29:29 +03:30
|
|
|
) {
|
|
|
|
|
await this.assertCanEditTreatment(actorUserId, organizationId);
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
const labCase = await this.prisma.labCase.findFirst({
|
2026-05-19 22:29:29 +03:30
|
|
|
where: {
|
2026-06-28 15:34:56 +03:30
|
|
|
id: labCaseId,
|
2026-05-19 22:29:29 +03:30
|
|
|
treatment: { organizationId },
|
|
|
|
|
},
|
|
|
|
|
include: {
|
2026-06-28 15:34:56 +03:30
|
|
|
treatment: { select: { providerUserId: true } },
|
2026-05-19 22:29:29 +03:30
|
|
|
sends: { select: { organizationId: true } },
|
2026-07-06 20:40:19 +03:30
|
|
|
details: {
|
|
|
|
|
include: {
|
|
|
|
|
detail: { select: { id: true, treatmentType: true, teeth: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
toothProsthesis: true,
|
2026-05-19 22:29:29 +03:30
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
if (!labCase) {
|
|
|
|
|
throw new NotFoundException('Lab case not found');
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
if (!labCase.destinationOrganizationId) {
|
|
|
|
|
throw new BadRequestException('Lab case has no destination organization');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (labCase.details.length === 0) {
|
|
|
|
|
throw new BadRequestException('Lab case must include at least one treatment detail');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
assertCompleteToothProsthesisMap(labCase);
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
if (labCase.treatment.providerUserId !== actorUserId) {
|
2026-05-19 22:29:29 +03:30
|
|
|
const membership = await this.getMembership(actorUserId, organizationId);
|
|
|
|
|
if (!membership?.isOwner) {
|
2026-06-28 15:34:56 +03:30
|
|
|
throw new ForbiddenException('Only the appointment provider can send this lab case');
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
|
2026-06-28 15:34:56 +03:30
|
|
|
if (!linkedOrgIds.has(labCase.destinationOrganizationId)) {
|
|
|
|
|
throw new BadRequestException('Destination organization is not an active linked counterpart');
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
const alreadySent = labCase.sends.some(
|
|
|
|
|
(s) => s.organizationId === labCase.destinationOrganizationId,
|
|
|
|
|
);
|
|
|
|
|
if (alreadySent) {
|
|
|
|
|
throw new BadRequestException('Lab case was already sent to the destination organization');
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
2026-06-28 15:34:56 +03:30
|
|
|
await tx.labCaseSend.create({
|
|
|
|
|
data: {
|
|
|
|
|
labCaseId,
|
|
|
|
|
organizationId: labCase.destinationOrganizationId!,
|
|
|
|
|
},
|
2026-05-19 22:29:29 +03:30
|
|
|
});
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
if (!labCase.sentAt) {
|
|
|
|
|
await tx.labCase.update({
|
|
|
|
|
where: { id: labCaseId },
|
2026-05-19 22:29:29 +03:30
|
|
|
data: { sentAt: now },
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-28 16:46:42 +03:30
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
await generateLabCaseTasks(tx, labCaseId, actorLanguage);
|
2026-05-19 22:29:29 +03:30
|
|
|
});
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
const refreshed = await this.prisma.labCase.findUniqueOrThrow({
|
|
|
|
|
where: { id: labCaseId },
|
2026-05-19 22:29:29 +03:30
|
|
|
include: {
|
2026-06-28 15:34:56 +03:30
|
|
|
details: {
|
|
|
|
|
include: {
|
|
|
|
|
detail: {
|
|
|
|
|
select: { id: true, clientKey: true, treatmentType: true, teeth: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-19 23:43:43 +03:30
|
|
|
sends: {
|
|
|
|
|
orderBy: [{ sentAt: 'asc' }],
|
|
|
|
|
include: { organization: { select: { id: true, name: true } } },
|
|
|
|
|
},
|
2026-07-06 20:40:19 +03:30
|
|
|
toothProsthesis: true,
|
2026-05-19 22:29:29 +03:30
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
return { success: true, data: this.mapLabCase(refreshed) };
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
async uploadDetailAttachments(
|
2026-05-19 22:29:29 +03:30
|
|
|
appointmentId: string,
|
2026-06-28 15:34:56 +03:30
|
|
|
detailClientKey: string,
|
2026-05-19 22:29:29 +03:30
|
|
|
files: Express.Multer.File[],
|
|
|
|
|
organizationId: string,
|
|
|
|
|
actorUserId: string,
|
|
|
|
|
) {
|
|
|
|
|
await this.assertCanEditTreatment(actorUserId, organizationId);
|
|
|
|
|
await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true);
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
if (!detailClientKey?.trim()) {
|
|
|
|
|
throw new BadRequestException('detailClientKey is required');
|
2026-05-19 22:29:29 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
const attachment = await this.prisma.treatmentDetailAttachment.create({
|
2026-05-19 22:29:29 +03:30
|
|
|
data: {
|
|
|
|
|
appointmentId,
|
2026-06-28 15:34:56 +03:30
|
|
|
detailClientKey,
|
2026-05-19 22:29:29 +03:30
|
|
|
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);
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
const attachment = await this.prisma.treatmentDetailAttachment.findFirst({
|
2026-05-19 22:29:29 +03:30
|
|
|
where: {
|
|
|
|
|
id: attachmentId,
|
|
|
|
|
OR: [
|
2026-06-28 15:34:56 +03:30
|
|
|
{ detail: { treatment: { organizationId } } },
|
2026-05-19 22:29:29 +03:30
|
|
|
{ appointmentId: { not: null } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
2026-06-28 15:34:56 +03:30
|
|
|
detail: { select: { treatment: { select: { organizationId: true } } } },
|
2026-05-19 22:29:29 +03:30
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!attachment) {
|
|
|
|
|
throw new NotFoundException('Attachment not found');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
if (attachment.detail && attachment.detail.treatment.organizationId !== organizationId) {
|
2026-05-19 22:29:29 +03:30
|
|
|
throw new NotFoundException('Attachment not found');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
if (!attachment.detail && attachment.appointmentId) {
|
2026-05-19 22:29:29 +03:30
|
|
|
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;
|
2026-06-28 15:34:56 +03:30
|
|
|
details: Array<{
|
2026-05-19 22:29:29 +03:30
|
|
|
id: string;
|
|
|
|
|
clientKey: string | null;
|
|
|
|
|
treatmentType: string;
|
|
|
|
|
teeth: unknown;
|
|
|
|
|
comment: string | null;
|
|
|
|
|
attachments: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
fileName: string;
|
|
|
|
|
mimeType: string;
|
|
|
|
|
sizeBytes: number;
|
|
|
|
|
}>;
|
2026-06-28 15:34:56 +03:30
|
|
|
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 };
|
|
|
|
|
}>;
|
2026-05-19 22:29:29 +03:30
|
|
|
}>;
|
|
|
|
|
}) {
|
2026-06-28 15:34:56 +03:30
|
|
|
const documents = treatment.details.flatMap((d) =>
|
|
|
|
|
d.attachments.map((a) => this.mapAttachment(a)),
|
2026-05-19 22:29:29 +03:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: treatment.id,
|
|
|
|
|
patientId: treatment.patientId,
|
|
|
|
|
appointmentId: treatment.appointmentId,
|
|
|
|
|
title: treatment.title,
|
|
|
|
|
treatmentAt: treatment.treatmentAt.toISOString(),
|
2026-06-28 15:34:56 +03:30
|
|
|
details: treatment.details.map((d) => this.mapDetail(d)),
|
|
|
|
|
labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)),
|
2026-05-19 22:29:29 +03:30
|
|
|
documents,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
private mapDetail(d: {
|
2026-05-19 22:29:29 +03:30
|
|
|
id: string;
|
|
|
|
|
clientKey?: string | null;
|
|
|
|
|
treatmentType: string;
|
|
|
|
|
teeth: unknown;
|
|
|
|
|
comment?: string | null;
|
|
|
|
|
attachments?: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
fileName: string;
|
|
|
|
|
mimeType: string;
|
|
|
|
|
sizeBytes: number;
|
|
|
|
|
}>;
|
2026-06-28 15:34:56 +03:30
|
|
|
labCaseLink?: {
|
|
|
|
|
labCase: {
|
|
|
|
|
id: string;
|
|
|
|
|
sentAt: Date | null;
|
|
|
|
|
destinationOrganizationId: string | null;
|
|
|
|
|
sends: Array<{
|
|
|
|
|
organizationId: string;
|
|
|
|
|
sentAt: Date;
|
|
|
|
|
organization?: { id: string; name: string };
|
|
|
|
|
}>;
|
|
|
|
|
};
|
|
|
|
|
} | null;
|
2026-05-19 22:29:29 +03:30
|
|
|
}) {
|
2026-06-28 15:34:56 +03:30
|
|
|
const labCase = d.labCaseLink?.labCase;
|
2026-05-19 22:29:29 +03:30
|
|
|
return {
|
2026-06-28 15:34:56 +03:30
|
|
|
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,
|
2026-05-19 23:43:43 +03:30
|
|
|
sends:
|
2026-06-28 15:34:56 +03:30
|
|
|
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 };
|
|
|
|
|
}>;
|
2026-07-06 20:40:19 +03:30
|
|
|
toothProsthesis?: Array<{
|
|
|
|
|
treatmentDetailId: string;
|
|
|
|
|
tooth: string;
|
|
|
|
|
prosthesisTypeCode: string;
|
|
|
|
|
}>;
|
2026-06-28 15:34:56 +03:30
|
|
|
}) {
|
|
|
|
|
return {
|
|
|
|
|
id: lc.id,
|
|
|
|
|
clientId: lc.clientKey ?? lc.id,
|
|
|
|
|
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
|
|
|
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
|
|
|
|
treatmentDetailIds: lc.details?.map((d) => d.treatmentDetailId) ?? [],
|
|
|
|
|
details: (lc.details ?? []).map((d) => ({
|
|
|
|
|
id: d.detail?.id ?? d.treatmentDetailId,
|
|
|
|
|
clientId: d.detail?.clientKey ?? d.treatmentDetailId,
|
|
|
|
|
treatmentType: d.detail?.treatmentType ?? '',
|
|
|
|
|
teeth: d.detail ? normalizeTeeth(d.detail.teeth) : [],
|
|
|
|
|
})),
|
2026-07-06 20:40:19 +03:30
|
|
|
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
|
|
|
|
treatmentDetailId: tp.treatmentDetailId,
|
|
|
|
|
tooth: tp.tooth,
|
|
|
|
|
prosthesisTypeCode: tp.prosthesisTypeCode,
|
|
|
|
|
})),
|
2026-06-28 15:34:56 +03:30
|
|
|
sends:
|
|
|
|
|
lc.sends?.map((s) => ({
|
2026-05-19 23:43:43 +03:30
|
|
|
organizationId: s.organizationId,
|
|
|
|
|
organizationName: s.organization?.name ?? 'Unknown organization',
|
|
|
|
|
sentAt: s.sentAt.toISOString(),
|
|
|
|
|
})) ?? [],
|
2026-05-19 22:29:29 +03:30
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 15:34:56 +03:30
|
|
|
private async ensurePatientExists(patientId: string) {
|
2026-06-28 14:49:37 +03:30
|
|
|
const patient = await this.prisma.patient.findUnique({
|
|
|
|
|
where: { id: patientId },
|
2026-05-19 22:29:29 +03:30
|
|
|
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 (m.isOwner) return;
|
|
|
|
|
const names = m.permissions.map((p) => p.permission.name);
|
|
|
|
|
if (names.includes('TAB_TREATMENT_READ') || names.includes('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 (m.isOwner) return;
|
|
|
|
|
const names = m.permissions.map((p) => p.permission.name);
|
|
|
|
|
if (names.includes('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, isActive: true },
|
|
|
|
|
include: { permissions: { include: { permission: true } } },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|