bugfix: clinic owner user access to other (dentist)staff's treatment plans terminated.
This commit is contained in:
26
backend/src/common/treatment-provider-scope.ts
Normal file
26
backend/src/common/treatment-provider-scope.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
/** Treatments the clinician owns — by plan provider or linked appointment provider. */
|
||||
export function treatmentProviderScopeWhere(
|
||||
actorUserId: string,
|
||||
): Pick<Prisma.TreatmentWhereInput, 'OR'> {
|
||||
return {
|
||||
OR: [
|
||||
{ providerUserId: actorUserId },
|
||||
{ appointment: { is: { providerUserId: actorUserId } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
type TreatmentProviderRow = {
|
||||
providerUserId: string | null;
|
||||
appointment?: { providerUserId: string } | null;
|
||||
};
|
||||
|
||||
export function isActorTreatmentProvider(
|
||||
treatment: TreatmentProviderRow,
|
||||
actorUserId: string,
|
||||
): boolean {
|
||||
if (treatment.providerUserId === actorUserId) return true;
|
||||
return treatment.appointment?.providerUserId === actorUserId;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { LabCaseCommentSide, LabCaseActivityType, Prisma } 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';
|
||||
|
||||
const commentInclude = {
|
||||
@@ -273,7 +274,20 @@ export class LabCaseCommentsService {
|
||||
clinicOrganizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
|
||||
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,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LAB_CASES_TAB_ACTIVITY_TYPES,
|
||||
LAB_TASKS_TAB_ACTIVITY_TYPES,
|
||||
} from '../../common/lab-case-activity';
|
||||
import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
@@ -126,7 +127,10 @@ export class LabCaseActivityService {
|
||||
CLINIC_TREATMENT_TAB_ACTIVITY_TYPES,
|
||||
{
|
||||
sentAt: { not: null },
|
||||
treatment: { organizationId },
|
||||
treatment: {
|
||||
organizationId,
|
||||
...treatmentProviderScopeWhere(userId),
|
||||
},
|
||||
},
|
||||
'CLINIC',
|
||||
);
|
||||
@@ -448,7 +452,11 @@ export class LabCaseActivityService {
|
||||
}
|
||||
: {
|
||||
id: labCaseId,
|
||||
treatment: { organizationId },
|
||||
sentAt: { not: null },
|
||||
treatment: {
|
||||
organizationId,
|
||||
...treatmentProviderScopeWhere(userId),
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} from './treatment.utils';
|
||||
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import {
|
||||
isActorTreatmentProvider,
|
||||
treatmentProviderScopeWhere,
|
||||
} from '../../common/treatment-provider-scope';
|
||||
|
||||
const sentLabCaseInclude = {
|
||||
treatment: {
|
||||
@@ -164,22 +168,12 @@ export class TreatmentsService {
|
||||
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 } } },
|
||||
],
|
||||
}),
|
||||
...treatmentProviderScopeWhere(actorUserId),
|
||||
},
|
||||
include: treatmentInclude,
|
||||
orderBy: [{ treatmentAt: 'desc' }, { createdAt: 'desc' }],
|
||||
@@ -197,16 +191,13 @@ export class TreatmentsService {
|
||||
await this.assertCanReadTreatment(actorUserId, organizationId);
|
||||
await this.ensurePatientExists(patientId);
|
||||
|
||||
const membership = await this.getMembership(actorUserId, organizationId);
|
||||
const isOwner = membership?.isOwner ?? false;
|
||||
|
||||
const labCases = await this.prisma.labCase.findMany({
|
||||
where: {
|
||||
sentAt: { not: null },
|
||||
treatment: {
|
||||
patientId,
|
||||
organizationId,
|
||||
...this.treatmentAccessFilter(isOwner, actorUserId),
|
||||
...treatmentProviderScopeWhere(actorUserId),
|
||||
},
|
||||
},
|
||||
include: sentLabCaseInclude,
|
||||
@@ -224,15 +215,12 @@ export class TreatmentsService {
|
||||
async listUnreadLabCases(organizationId: string, actorUserId: string) {
|
||||
await this.assertCanReadTreatment(actorUserId, organizationId);
|
||||
|
||||
const membership = await this.getMembership(actorUserId, organizationId);
|
||||
const isOwner = membership?.isOwner ?? false;
|
||||
|
||||
const labCases = await this.prisma.labCase.findMany({
|
||||
where: {
|
||||
sentAt: { not: null },
|
||||
treatment: {
|
||||
organizationId,
|
||||
...this.treatmentAccessFilter(isOwner, actorUserId),
|
||||
...treatmentProviderScopeWhere(actorUserId),
|
||||
},
|
||||
},
|
||||
include: sentLabCaseInclude,
|
||||
@@ -258,17 +246,6 @@ export class TreatmentsService {
|
||||
return { success: true, data: this.sortLabCaseSummaries(summaries) };
|
||||
}
|
||||
|
||||
private treatmentAccessFilter(isOwner: boolean, actorUserId: string) {
|
||||
return isOwner
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ providerUserId: actorUserId },
|
||||
{ appointment: { is: { providerUserId: actorUserId } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async mapSentLabCaseSummaries(
|
||||
labCases: SentLabCaseRow[],
|
||||
actorUserId: string,
|
||||
@@ -367,7 +344,6 @@ export class TreatmentsService {
|
||||
appointmentId,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
false,
|
||||
);
|
||||
|
||||
const treatment = await this.prisma.treatment.findFirst({
|
||||
@@ -392,7 +368,6 @@ export class TreatmentsService {
|
||||
appointmentId,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
true,
|
||||
);
|
||||
|
||||
for (const d of dto.details) {
|
||||
@@ -537,7 +512,6 @@ export class TreatmentsService {
|
||||
appointmentId,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
true,
|
||||
);
|
||||
|
||||
const treatment = await this.prisma.treatment.findFirst({
|
||||
@@ -615,7 +589,13 @@ export class TreatmentsService {
|
||||
for (const [index, lc] of dto.labCases.entries()) {
|
||||
if (lc.id && sentLabCaseIds.has(lc.id)) {
|
||||
if (lc.dueDate !== undefined) {
|
||||
await this.updateLabCaseDueDateInTx(tx, lc.id, organizationId, lc.dueDate);
|
||||
await this.updateLabCaseDueDateInTx(
|
||||
tx,
|
||||
lc.id,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
lc.dueDate,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -710,7 +690,12 @@ export class TreatmentsService {
|
||||
treatment: { organizationId },
|
||||
},
|
||||
include: {
|
||||
treatment: { select: { providerUserId: true } },
|
||||
treatment: {
|
||||
select: {
|
||||
providerUserId: true,
|
||||
appointment: { select: { providerUserId: true } },
|
||||
},
|
||||
},
|
||||
sends: { select: { organizationId: true } },
|
||||
details: {
|
||||
include: {
|
||||
@@ -739,11 +724,8 @@ export class TreatmentsService {
|
||||
|
||||
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');
|
||||
}
|
||||
if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) {
|
||||
throw new ForbiddenException('Only the treatment provider can send this lab case');
|
||||
}
|
||||
|
||||
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
|
||||
@@ -821,7 +803,13 @@ export class TreatmentsService {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
await this.updateLabCaseDueDateInTx(tx, labCaseId, organizationId, dto.dueDate ?? null);
|
||||
await this.updateLabCaseDueDateInTx(
|
||||
tx,
|
||||
labCaseId,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
dto.dueDate ?? null,
|
||||
);
|
||||
return tx.labCase.findFirstOrThrow({
|
||||
where: { id: labCaseId },
|
||||
include: {
|
||||
@@ -862,12 +850,16 @@ export class TreatmentsService {
|
||||
tx: Prisma.TransactionClient,
|
||||
labCaseId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
dueDateInput?: string | null,
|
||||
) {
|
||||
const labCase = await tx.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
treatment: { organizationId },
|
||||
treatment: {
|
||||
organizationId,
|
||||
...treatmentProviderScopeWhere(actorUserId),
|
||||
},
|
||||
sentAt: { not: null },
|
||||
},
|
||||
include: { tasks: { select: { status: true } } },
|
||||
@@ -902,7 +894,7 @@ export class TreatmentsService {
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true);
|
||||
await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId);
|
||||
|
||||
if (!detailClientKey?.trim()) {
|
||||
throw new BadRequestException('detailClientKey is required');
|
||||
@@ -956,7 +948,14 @@ export class TreatmentsService {
|
||||
where: {
|
||||
id: attachmentId,
|
||||
OR: [
|
||||
{ detail: { treatment: { organizationId } } },
|
||||
{
|
||||
detail: {
|
||||
treatment: {
|
||||
organizationId,
|
||||
...treatmentProviderScopeWhere(actorUserId),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ appointmentId: { not: null } },
|
||||
],
|
||||
},
|
||||
@@ -975,7 +974,11 @@ export class TreatmentsService {
|
||||
|
||||
if (!attachment.detail && attachment.appointmentId) {
|
||||
const appointment = await this.prisma.appointment.findFirst({
|
||||
where: { id: attachment.appointmentId, organizationId },
|
||||
where: {
|
||||
id: attachment.appointmentId,
|
||||
organizationId,
|
||||
providerUserId: actorUserId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!appointment) {
|
||||
@@ -1228,7 +1231,6 @@ export class TreatmentsService {
|
||||
appointmentId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
requireProviderMatch: boolean,
|
||||
) {
|
||||
const appointment = await this.prisma.appointment.findFirst({
|
||||
where: { id: appointmentId, organizationId },
|
||||
@@ -1244,12 +1246,8 @@ export class TreatmentsService {
|
||||
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');
|
||||
}
|
||||
if (appointment.providerUserId !== actorUserId) {
|
||||
throw new ForbiddenException('You are not the provider for this appointment');
|
||||
}
|
||||
|
||||
return appointment;
|
||||
|
||||
Reference in New Issue
Block a user