improvement: a flow added for owner users to make it possible for them to participate in treatments or tasks.

This commit is contained in:
2026-07-12 15:11:50 +03:30
parent 4208d15efb
commit 39935688ad
20 changed files with 1122 additions and 154 deletions

View File

@@ -0,0 +1,96 @@
import {
ownerPermissionsForOrgType,
type OrganizationTypeName,
} from './organization-type';
import { normalizeTabPermissions } from './permissions';
export const CLINIC_PARTICIPATION_PERMISSIONS = [
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
] as const;
export const LAB_PARTICIPATION_PERMISSIONS = [
'TAB_TASKS_READ',
'TAB_TASKS_EDIT',
] as const;
const CLINIC_PARTICIPATION_SET = new Set<string>(CLINIC_PARTICIPATION_PERMISSIONS);
const LAB_PARTICIPATION_SET = new Set<string>(LAB_PARTICIPATION_PERMISSIONS);
export type MembershipWithPermissions = {
isOwner: boolean;
organization: {
plan?: { name: string; maxUsers?: number; price?: number } | null;
planId?: string | null;
type?: { name: string };
};
permissions?: Array<{ permission: { name: string } }>;
};
export function getOrgTypeFromMembership(
membership: MembershipWithPermissions,
): OrganizationTypeName {
return membership.organization.type?.name === 'LAB' ? 'LAB' : 'CLINIC';
}
export function hasActivePlan(membership: MembershipWithPermissions): boolean {
if (membership.organization.plan != null) {
return true;
}
return Boolean(membership.organization.planId);
}
export function getStoredPermissionNames(
membership: MembershipWithPermissions,
): string[] {
return membership.permissions?.map((p) => p.permission.name) ?? [];
}
export function getEffectivePermissionNames(
membership: MembershipWithPermissions,
): string[] {
const stored = getStoredPermissionNames(membership);
if (!membership.isOwner) {
return normalizeTabPermissions(stored);
}
const orgType = getOrgTypeFromMembership(membership);
const base = ownerPermissionsForOrgType(orgType, hasActivePlan(membership));
return normalizeTabPermissions([...base, ...stored]);
}
export function hasEffectivePermission(
membership: MembershipWithPermissions,
permission: string,
): boolean {
return getEffectivePermissionNames(membership).includes(permission);
}
export function participationPermissionsForOrgType(
orgType: OrganizationTypeName,
): readonly string[] {
return orgType === 'LAB'
? LAB_PARTICIPATION_PERMISSIONS
: CLINIC_PARTICIPATION_PERMISSIONS;
}
export function participatesInTreatments(
membership: MembershipWithPermissions,
): boolean {
if (getOrgTypeFromMembership(membership) !== 'CLINIC') {
return false;
}
return getStoredPermissionNames(membership).includes('TAB_TREATMENT_EDIT');
}
export function participatesInTasks(membership: MembershipWithPermissions): boolean {
if (getOrgTypeFromMembership(membership) !== 'LAB') {
return false;
}
return getStoredPermissionNames(membership).includes('TAB_TASKS_EDIT');
}
export function isParticipationPermission(name: string): boolean {
return CLINIC_PARTICIPATION_SET.has(name) || LAB_PARTICIPATION_SET.has(name);
}

View File

@@ -49,18 +49,27 @@ export function filterPermissionsForOrgType(
return normalizeTabPermissions(names.filter((n) => allowed.has(n)));
}
/** Owner opt-in permissions — granted via MembershipPermission when owner chooses to participate. */
const OWNER_OPT_IN_CLINIC = new Set<string>(['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT']);
const OWNER_OPT_IN_LAB = new Set<string>(['TAB_TASKS_READ', 'TAB_TASKS_EDIT']);
function ownerBasePermissions(orgType: OrganizationTypeName): readonly string[] {
const all = orgType === 'LAB' ? LAB_TAB_PERMISSIONS : CLINIC_TAB_PERMISSIONS;
const optIn = orgType === 'LAB' ? OWNER_OPT_IN_LAB : OWNER_OPT_IN_CLINIC;
return all.filter((p) => !optIn.has(p));
}
export function ownerPermissionsForOrgType(
orgType: OrganizationTypeName,
hasActivePlan: boolean,
): string[] {
const base = ownerBasePermissions(orgType);
if (hasActivePlan) {
return orgType === 'LAB' ? [...LAB_TAB_PERMISSIONS] : [...CLINIC_TAB_PERMISSIONS];
return [...base];
}
const readOnly = (perms: readonly string[]) =>
normalizeTabPermissions(perms.filter((p) => p.endsWith('_READ')));
return orgType === 'LAB' ? readOnly(LAB_TAB_PERMISSIONS) : readOnly(CLINIC_TAB_PERMISSIONS);
return normalizeTabPermissions(base.filter((p) => p.endsWith('_READ')));
}
export async function getOrganizationTypeName(

View File

@@ -15,6 +15,7 @@ import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto';
import { UpdateAppointmentDto } from './dto/update-appointment.dto';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { hasEffectivePermission } from '../../common/membership-permissions';
const MS_PER_DAY = 86_400_000;
@@ -39,8 +40,7 @@ export class AppointmentsService {
const members = await this.prisma.membership.findMany({
where: {
organizationId,
isOwner: false,
isActive: true,
OR: [{ isOwner: true }, { isActive: true }],
permissions: {
some: {
permission: {
@@ -308,16 +308,10 @@ export class AppointmentsService {
if (!m) {
throw new BadRequestException('Provider is not a member of this organization');
}
if (m.isOwner) {
throw new BadRequestException(
'Appointments must be assigned to staff with treatment access, not the organization owner',
);
}
if (!m.isActive) {
if (!m.isOwner && !m.isActive) {
throw new BadRequestException('Provider is not an active staff member');
}
const names = m.permissions.map((p) => p.permission.name);
if (!names.includes('TAB_TREATMENT_EDIT')) {
if (!hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
throw new BadRequestException('Provider does not have treatment edit access');
}
}
@@ -375,8 +369,15 @@ export class AppointmentsService {
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },
include: { permissions: { include: { permission: true } } },
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
}
}

View File

@@ -11,6 +11,7 @@ import {
HttpStatus,
Get,
Patch,
Put,
UnauthorizedException,
} from '@nestjs/common';
import type { Response } from 'express';
@@ -36,6 +37,8 @@ import {
ForgotPasswordVerifyDto,
} from './dto/forgot-password.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { UpdateParticipationDto } from './dto/update-participation.dto';
import { UpsertWorkingHoursDto } from '../staff/dto/upsert-working-hours.dto';
@ApiTags('auth')
@Controller('auth')
@@ -200,6 +203,46 @@ export class AuthController {
return result;
}
@Get('profile/participation')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get owner participation settings for current organization' })
async getParticipation(@Req() req) {
return this.authService.getParticipation(req.user.id, req.user.organizationId);
}
@Patch('profile/participation')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Enable or disable owner participation in treatments/tasks' })
async updateParticipation(@Req() req, @Body() dto: UpdateParticipationDto) {
return this.authService.updateParticipation(
req.user.id,
req.user.organizationId,
dto,
);
}
@Get('profile/working-hours')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get working hours for participating clinic owner' })
async getMyWorkingHours(@Req() req) {
return this.authService.getMyWorkingHours(req.user.id, req.user.organizationId);
}
@Put('profile/working-hours')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Save working hours for participating clinic owner' })
async upsertMyWorkingHours(@Req() req, @Body() dto: UpsertWorkingHoursDto) {
return this.authService.upsertMyWorkingHours(
req.user.id,
req.user.organizationId,
dto,
);
}
@Post('forgot-password/send-code')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send forgot-password SMS verification code' })

View File

@@ -9,11 +9,13 @@ import { PrismaService } from '../../../prisma/prisma.service';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { SmsModule } from '../sms/sms.module';
import { StaffModule } from '../staff/staff.module';
@Module({
imports: [
PassportModule,
SmsModule,
StaffModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({

View File

@@ -21,6 +21,11 @@ import {
} from './dto/update-language.dto';
import { JwtPayload } from './interfaces/jwt-payload.interface';
import { ownerPermissionsForOrgType, type OrganizationTypeName } from '../../common/organization-type';
import { getEffectivePermissionNames, getOrgTypeFromMembership, hasActivePlan, participatesInTasks, participatesInTreatments, participationPermissionsForOrgType } from '../../common/membership-permissions';
import { assertClinicOrganization, assertLabOrganization } from '../../common/organization-type';
import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
import { UpsertWorkingHoursDto } from '../staff/dto/upsert-working-hours.dto';
import { UpdateParticipationDto } from './dto/update-participation.dto';
import { SmsService } from '../sms/sms.service';
import {
ForgotPasswordSendCodeDto,
@@ -75,6 +80,7 @@ export class AuthService {
private jwtService: JwtService,
private configService: ConfigService,
private smsService: SmsService,
private staffWorkingHoursService: StaffWorkingHoursService,
) { }
private accessJwtSignOptions(): JwtSignOptions {
@@ -999,17 +1005,12 @@ export class AuthService {
isOwner: boolean;
organization: {
plan?: { name: string; maxUsers: number; price: number } | null;
planId?: string | null;
type?: { name: string };
};
permissions?: Array<{ permission: { name: string } }>;
}): string[] {
if (membership.isOwner) {
const orgType = (membership.organization.type?.name === 'LAB'
? 'LAB'
: 'CLINIC') as OrganizationTypeName;
return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.plan));
}
return membership.permissions?.map((p) => p.permission.name) || [];
return getEffectivePermissionNames(membership);
}
/**
@@ -1161,4 +1162,204 @@ export class AuthService {
mobile: user.mobile ?? null,
};
}
private async getOwnerMembership(userId: string, organizationId: string) {
if (!organizationId) {
throw new BadRequestException('Organization is not selected');
}
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId, isOwner: true },
include: {
organization: {
include: { type: true, plan: true },
},
permissions: { include: { permission: true } },
},
});
if (!membership) {
throw new ForbiddenException('Only organization owners can manage participation');
}
return membership;
}
async getParticipation(userId: string, organizationId: string) {
const membership = await this.getOwnerMembership(userId, organizationId);
const orgType = getOrgTypeFromMembership(membership);
const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({
where: { membershipId: membership.id },
include: { blocks: true },
});
return {
success: true,
data: {
membershipId: membership.id,
orgType,
participatesInTreatments: participatesInTreatments(membership),
participatesInTasks: participatesInTasks(membership),
hasWorkingHours: (schedule?.blocks.length ?? 0) > 0,
},
};
}
async updateParticipation(
userId: string,
organizationId: string,
dto: UpdateParticipationDto,
) {
const membership = await this.getOwnerMembership(userId, organizationId);
const orgType = getOrgTypeFromMembership(membership);
if (!hasActivePlan(membership)) {
throw new ForbiddenException(
'An active subscription is required to participate in treatments or tasks',
);
}
if (dto.participate) {
await this.grantOwnerParticipation(membership.id, orgType);
} else {
await this.revokeOwnerParticipation(membership, orgType);
}
const updated = await this.prisma.membership.findFirst({
where: { id: membership.id },
include: {
organization: { include: { type: true, plan: true } },
permissions: { include: { permission: true } },
},
});
const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({
where: { membershipId: membership.id },
include: { blocks: true },
});
return {
success: true,
data: {
membershipId: membership.id,
orgType,
participatesInTreatments: participatesInTreatments(updated!),
participatesInTasks: participatesInTasks(updated!),
hasWorkingHours: (schedule?.blocks.length ?? 0) > 0,
permissions: getEffectivePermissionNames(updated!),
},
};
}
async getMyWorkingHours(userId: string, organizationId: string) {
const membership = await this.getOwnerMembership(userId, organizationId);
if (getOrgTypeFromMembership(membership) !== 'CLINIC') {
throw new BadRequestException('Working hours are only available for clinic organizations');
}
return this.staffWorkingHoursService.getMyWorkingHours(userId, organizationId);
}
async upsertMyWorkingHours(
userId: string,
organizationId: string,
dto: UpsertWorkingHoursDto,
) {
const membership = await this.getOwnerMembership(userId, organizationId);
if (getOrgTypeFromMembership(membership) !== 'CLINIC') {
throw new BadRequestException('Working hours are only available for clinic organizations');
}
if (!participatesInTreatments(membership)) {
throw new ForbiddenException(
'Enable treatment participation before setting working hours',
);
}
return this.staffWorkingHoursService.upsertMyWorkingHours(userId, organizationId, dto);
}
private async grantOwnerParticipation(
membershipId: string,
orgType: OrganizationTypeName,
) {
const participationNames = participationPermissionsForOrgType(orgType).filter((p) =>
p.endsWith('_EDIT'),
);
const permissionRows = await this.prisma.permission.findMany({
where: { name: { in: [...participationNames] } },
});
if (permissionRows.length === 0) {
throw new InternalServerErrorException('Participation permissions are not configured');
}
const participationIds = (
await this.prisma.permission.findMany({
where: { name: { in: [...participationPermissionsForOrgType(orgType)] } },
select: { id: true },
})
).map((p) => p.id);
await this.prisma.$transaction(async (tx) => {
await tx.membershipPermission.deleteMany({
where: {
membershipId,
permissionId: { in: participationIds },
},
});
await tx.membershipPermission.createMany({
data: permissionRows.map((p) => ({
membershipId,
permissionId: p.id,
})),
skipDuplicates: true,
});
});
}
private async revokeOwnerParticipation(
membership: {
id: string;
userId: string;
organizationId: string;
organization: { id: string };
},
orgType: OrganizationTypeName,
) {
if (orgType === 'CLINIC') {
await assertClinicOrganization(this.prisma, membership.organizationId);
const futureAppointment = await this.prisma.appointment.findFirst({
where: {
organizationId: membership.organizationId,
providerUserId: membership.userId,
startAt: { gte: new Date() },
},
select: { id: true },
});
if (futureAppointment) {
throw new ConflictException(
'You cannot stop participating in treatments while you have future appointments assigned. Reassign or cancel those appointments first.',
);
}
} else {
await assertLabOrganization(this.prisma, membership.organizationId);
}
const participationIds = (
await this.prisma.permission.findMany({
where: { name: { in: [...participationPermissionsForOrgType(orgType)] } },
select: { id: true },
})
).map((p) => p.id);
await this.prisma.membershipPermission.deleteMany({
where: {
membershipId: membership.id,
permissionId: { in: participationIds },
},
});
}
}

View File

@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class UpdateParticipationDto {
@IsBoolean()
participate: boolean;
}

View File

@@ -6,6 +6,7 @@ import {
import { LabCaseCommentSide, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
import { hasEffectivePermission } from '../../common/membership-permissions';
const commentInclude = {
authorUser: { select: { id: true, name: true } },
@@ -206,15 +207,20 @@ export class LabCaseCommentsService {
}
const membership = await this.prisma.membership.findFirst({
where: { userId: actorUserId, organizationId: labOrganizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
where: {
userId: actorUserId,
organizationId: labOrganizationId,
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 (membership.isOwner) return;
const names = membership.permissions.map((p) => p.permission.name);
if (!names.includes('TAB_TASKS_EDIT')) {
if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) {
throw new ForbiddenException('You do not have access to task comments');
}
}
@@ -244,15 +250,23 @@ export class LabCaseCommentsService {
) {
await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
const membership = await this.prisma.membership.findFirst({
where: { userId: actorUserId, organizationId: clinicOrganizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
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 (membership.isOwner) return;
const names = membership.permissions.map((p) => p.permission.name);
if (names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) {
if (
hasEffectivePermission(membership, 'TAB_TREATMENT_READ') ||
hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')
) {
return;
}
throw new ForbiddenException('You do not have access to treatment cases');

View File

@@ -13,6 +13,9 @@ import {
type WorkingHoursBlockInput,
} from '../../common/working-hours';
import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto';
import {
hasEffectivePermission,
} from '../../common/membership-permissions';
@Injectable()
export class StaffWorkingHoursService {
@@ -21,7 +24,7 @@ export class StaffWorkingHoursService {
async getWorkingHours(actorUserId: string, organizationId: string, membershipId: string) {
await this.assertCanViewStaff(actorUserId, organizationId);
const membership = await this.findMembership(membershipId, organizationId);
const membership = await this.findStaffMembership(membershipId, organizationId);
const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({
where: { membershipId: membership.id },
include: {
@@ -63,7 +66,62 @@ export class StaffWorkingHoursService {
) {
await this.assertCanEditStaff(actorUserId, organizationId);
const membership = await this.findMembership(membershipId, organizationId);
const membership = await this.findStaffMembership(membershipId, organizationId);
return this.persistWorkingHours(membership, organizationId, dto);
}
async getMyWorkingHours(userId: string, organizationId: string) {
const membership = await this.findOwnerMembership(userId, organizationId);
await this.assertOwnerCanManageWorkingHours(membership);
const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({
where: { membershipId: membership.id },
include: {
blocks: { orderBy: [{ dayOfWeek: 'asc' }, { sortOrder: 'asc' }, { startMinute: 'asc' }] },
},
});
if (!schedule) {
return {
success: true,
data: {
autoRepeatWeekly: true,
blocks: [],
hasWorkingHours: false,
},
};
}
return {
success: true,
data: {
autoRepeatWeekly: schedule.autoRepeatWeekly,
blocks: schedule.blocks.map((b) => ({
dayOfWeek: b.dayOfWeek,
startMinute: b.startMinute,
endMinute: b.endMinute,
sortOrder: b.sortOrder,
})),
hasWorkingHours: schedule.blocks.length > 0,
},
};
}
async upsertMyWorkingHours(
userId: string,
organizationId: string,
dto: UpsertWorkingHoursDto,
) {
const membership = await this.findOwnerMembership(userId, organizationId);
await this.assertOwnerCanManageWorkingHours(membership);
return this.persistWorkingHours(membership, organizationId, dto);
}
private async persistWorkingHours(
membership: { id: string; userId: string },
organizationId: string,
dto: UpsertWorkingHoursDto,
) {
const validationError = validateWorkingHoursBlocks(dto.blocks);
if (validationError) {
throw new BadRequestException(validationError);
@@ -205,7 +263,7 @@ export class StaffWorkingHoursService {
);
}
private async findMembership(membershipId: string, organizationId: string) {
private async findStaffMembership(membershipId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
select: { id: true, isOwner: true, userId: true },
@@ -219,6 +277,32 @@ export class StaffWorkingHoursService {
return membership;
}
private async findOwnerMembership(userId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId, isOwner: true },
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
if (!membership) {
throw new ForbiddenException('Only organization owners can manage their working hours');
}
return membership;
}
private async assertOwnerCanManageWorkingHours(membership: {
isOwner: boolean;
permissions: { permission: { name: string } }[];
organization: { type: { name: string }; plan?: { name: string } | null; planId?: string | null };
}) {
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
throw new ForbiddenException(
'Enable treatment participation before setting working hours',
);
}
}
private async assertCanViewStaff(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canViewStaff(actor)) {

View File

@@ -13,6 +13,7 @@ import {
} from '../catalog/catalog-label.service';
import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
import { hasEffectivePermission } from '../../common/membership-permissions';
const taskListInclude = {
lastStatusChangedBy: { select: { id: true, name: true } },
@@ -288,9 +289,10 @@ export class TasksService {
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_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) {
if (
hasEffectivePermission(m, 'TAB_TASKS_READ') ||
hasEffectivePermission(m, 'TAB_TASKS_EDIT')
) {
return;
}
throw new ForbiddenException('You do not have access to tasks');
@@ -301,9 +303,7 @@ export class TasksService {
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_TASKS_EDIT')) {
if (hasEffectivePermission(m, 'TAB_TASKS_EDIT')) {
return;
}
throw new ForbiddenException('You cannot update tasks');
@@ -311,8 +311,15 @@ export class TasksService {
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
}
}

View File

@@ -7,12 +7,11 @@ import { LabTaskStatus, LinkStatus, CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
isUnlimitedSeats,
normalizeTabPermissions,
} from '../../common/permissions';
import {
OrganizationTypeName,
ownerPermissionsForOrgType,
} from '../../common/organization-type';
import { getEffectivePermissionNames } from '../../common/membership-permissions';
import {
CatalogLabelService,
normalizeCatalogLocale,
@@ -565,16 +564,10 @@ export class TodayService {
const members = await this.prisma.membership.findMany({
where: {
organizationId,
isActive: true,
OR: [
{ isOwner: true },
{
isOwner: false,
permissions: {
some: { permission: { name: editPermission } },
},
},
],
OR: [{ isOwner: true }, { isActive: true }],
permissions: {
some: { permission: { name: editPermission } },
},
},
select: { userId: true, isOwner: true },
});
@@ -605,10 +598,7 @@ export class TodayService {
code: member.userId,
label: nameById.get(member.userId) ?? member.userId,
count: countsByUser.get(member.userId) ?? 0,
isOwner: member.isOwner,
}))
.filter((row) => !row.isOwner || row.count > 0)
.map(({ code, label, count }) => ({ code, label, count }))
.sort((a, b) => b.count - a.count);
return rows.length >= 2 ? rows : undefined;
@@ -834,8 +824,7 @@ export class TodayService {
const members = await this.prisma.membership.findMany({
where: {
organizationId,
isOwner: false,
isActive: true,
OR: [{ isOwner: true }, { isActive: true }],
permissions: {
some: {
permission: { name: 'TAB_TREATMENT_EDIT' },
@@ -1184,19 +1173,12 @@ export class TodayService {
isOwner: boolean;
organization: {
planId: string | null;
plan?: { name: string } | null;
type: { name: string };
};
permissions: { permission: { name: string } }[];
}): string[] {
if (membership.isOwner) {
const orgType = (membership.organization.type.name === 'LAB'
? 'LAB'
: 'CLINIC') as OrganizationTypeName;
return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.planId));
}
return normalizeTabPermissions(
membership.permissions.map((p) => p.permission.name),
);
return getEffectivePermissionNames(membership);
}
private assertCanViewToday(isOwner: boolean, permissionNames: string[]) {
@@ -1220,15 +1202,13 @@ export class TodayService {
);
}
private canViewTreatment(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
private canViewTreatment(_isOwner: boolean, names: string[]): boolean {
return names.some((p) =>
['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'].includes(p),
);
}
private canViewMyAppointmentsWeekChart(isOwner: boolean, names: string[]): boolean {
if (isOwner) return false;
private canViewMyAppointmentsWeekChart(_isOwner: boolean, names: string[]): boolean {
return names.includes('TAB_TREATMENT_EDIT');
}
@@ -1239,15 +1219,13 @@ export class TodayService {
);
}
private canViewTasks(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
private canViewTasks(_isOwner: boolean, names: string[]): boolean {
return names.some((p) =>
['TAB_TASKS_READ', 'TAB_TASKS_EDIT'].includes(p),
);
}
private canEditTreatment(isOwner: boolean, names: string[]): boolean {
if (isOwner) return true;
private canEditTreatment(_isOwner: boolean, names: string[]): boolean {
return names.includes('TAB_TREATMENT_EDIT');
}

View File

@@ -21,6 +21,7 @@ import {
normalizeTeeth,
} from './treatment.utils';
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
import { hasEffectivePermission } from '../../common/membership-permissions';
const treatmentInclude = {
details: {
@@ -923,9 +924,10 @@ export class TreatmentsService {
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')) {
if (
hasEffectivePermission(m, 'TAB_TREATMENT_READ') ||
hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')
) {
return;
}
throw new ForbiddenException('You do not have access to treatments');
@@ -936,9 +938,7 @@ export class TreatmentsService {
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')) {
if (hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
return;
}
throw new ForbiddenException('You cannot edit treatments');
@@ -946,8 +946,15 @@ export class TreatmentsService {
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
where: {
userId,
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
}
}