feature: phase1 - org-type navigation, Cases permissions, staff filtering, and route guards.
This commit is contained in:
25
backend/src/common/guards/clinic-org.guard.ts
Normal file
25
backend/src/common/guards/clinic-org.guard.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { assertClinicOrganization } from '../../common/organization-type';
|
||||
|
||||
@Injectable()
|
||||
export class ClinicOrgGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<{ user?: { organizationId?: string } }>();
|
||||
const organizationId = request.user?.organizationId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new UnauthorizedException('Organization is not selected');
|
||||
}
|
||||
|
||||
await assertClinicOrganization(this.prisma, organizationId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
100
backend/src/common/organization-type.ts
Normal file
100
backend/src/common/organization-type.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ALL_TAB_PERMISSIONS, normalizeTabPermissions } from './permissions';
|
||||
|
||||
export type OrganizationTypeName = 'CLINIC' | 'LAB';
|
||||
|
||||
const CLINIC_ONLY_PERMISSIONS = new Set<string>([
|
||||
'TAB_PATIENTS_READ',
|
||||
'TAB_PATIENTS_EDIT',
|
||||
'TAB_APPOINTMENTS_READ',
|
||||
'TAB_APPOINTMENTS_EDIT',
|
||||
'TAB_TREATMENT_READ',
|
||||
'TAB_TREATMENT_EDIT',
|
||||
]);
|
||||
|
||||
const LAB_ONLY_PERMISSIONS = new Set<string>(['TAB_CASES_READ', 'TAB_CASES_EDIT']);
|
||||
|
||||
const SHARED_PERMISSIONS = ALL_TAB_PERMISSIONS.filter(
|
||||
(p) => !CLINIC_ONLY_PERMISSIONS.has(p) && !LAB_ONLY_PERMISSIONS.has(p),
|
||||
);
|
||||
|
||||
export const CLINIC_TAB_PERMISSIONS = [
|
||||
...SHARED_PERMISSIONS,
|
||||
...CLINIC_ONLY_PERMISSIONS,
|
||||
] as const;
|
||||
|
||||
export const LAB_TAB_PERMISSIONS = [
|
||||
...SHARED_PERMISSIONS,
|
||||
...LAB_ONLY_PERMISSIONS,
|
||||
] as const;
|
||||
|
||||
const CLINIC_TAB_SET = new Set<string>(CLINIC_TAB_PERMISSIONS);
|
||||
const LAB_TAB_SET = new Set<string>(LAB_TAB_PERMISSIONS);
|
||||
|
||||
export function permissionsAllowedForOrgType(orgType: OrganizationTypeName): Set<string> {
|
||||
return orgType === 'LAB' ? LAB_TAB_SET : CLINIC_TAB_SET;
|
||||
}
|
||||
|
||||
export function filterPermissionsForOrgType(
|
||||
names: string[],
|
||||
orgType: OrganizationTypeName,
|
||||
): string[] {
|
||||
const allowed = permissionsAllowedForOrgType(orgType);
|
||||
return normalizeTabPermissions(names.filter((n) => allowed.has(n)));
|
||||
}
|
||||
|
||||
export function ownerPermissionsForOrgType(
|
||||
orgType: OrganizationTypeName,
|
||||
hasActivePlan: boolean,
|
||||
): string[] {
|
||||
if (hasActivePlan) {
|
||||
return orgType === 'LAB' ? [...LAB_TAB_PERMISSIONS] : [...CLINIC_TAB_PERMISSIONS];
|
||||
}
|
||||
|
||||
const readOnly = (perms: readonly string[]) =>
|
||||
normalizeTabPermissions(perms.filter((p) => p.endsWith('_READ')));
|
||||
|
||||
return orgType === 'LAB' ? readOnly(LAB_TAB_PERMISSIONS) : readOnly(CLINIC_TAB_PERMISSIONS);
|
||||
}
|
||||
|
||||
export async function getOrganizationTypeName(
|
||||
prisma: PrismaService,
|
||||
organizationId: string,
|
||||
): Promise<OrganizationTypeName> {
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { type: { select: { name: true } } },
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
|
||||
const name = org.type.name;
|
||||
if (name !== 'CLINIC' && name !== 'LAB') {
|
||||
throw new ForbiddenException('Unknown organization type');
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
export async function assertClinicOrganization(
|
||||
prisma: PrismaService,
|
||||
organizationId: string,
|
||||
): Promise<void> {
|
||||
const type = await getOrganizationTypeName(prisma, organizationId);
|
||||
if (type !== 'CLINIC') {
|
||||
throw new ForbiddenException('This action is only available for clinic organizations');
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertLabOrganization(
|
||||
prisma: PrismaService,
|
||||
organizationId: string,
|
||||
): Promise<void> {
|
||||
const type = await getOrganizationTypeName(prisma, organizationId);
|
||||
if (type !== 'LAB') {
|
||||
throw new ForbiddenException('This action is only available for lab organizations');
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export const ALL_TAB_PERMISSIONS = [
|
||||
'TAB_APPOINTMENTS_EDIT',
|
||||
'TAB_TREATMENT_READ',
|
||||
'TAB_TREATMENT_EDIT',
|
||||
'TAB_CASES_READ',
|
||||
'TAB_CASES_EDIT',
|
||||
'TAB_BILLING_READ',
|
||||
'TAB_BILLING_EDIT',
|
||||
'TAB_REPORTS_READ',
|
||||
@@ -40,6 +42,7 @@ const EDIT_TO_READ: Record<string, string> = {
|
||||
TAB_STAFF_EDIT: 'TAB_STAFF_READ',
|
||||
TAB_ORGANIZATIONS_EDIT: 'TAB_ORGANIZATIONS_READ',
|
||||
TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ',
|
||||
TAB_CASES_EDIT: 'TAB_CASES_READ',
|
||||
TAB_BILLING_EDIT: 'TAB_BILLING_READ',
|
||||
TAB_REPORTS_EDIT: 'TAB_REPORTS_READ',
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AppointmentsService } from './appointments.service';
|
||||
import { ColumnProvidersQueryDto } from './dto/column-providers-query.dto';
|
||||
@@ -20,7 +21,7 @@ import { UpdateAppointmentDto } from './dto/update-appointment.dto';
|
||||
|
||||
@ApiTags('appointments')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, ClinicOrgGuard)
|
||||
@Controller('appointments')
|
||||
export class AppointmentsController {
|
||||
constructor(private readonly appointmentsService: AppointmentsService) {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||
import { StaffModule } from '../staff/staff.module';
|
||||
import { AppointmentsController } from './appointments.controller';
|
||||
import { AppointmentsService } from './appointments.service';
|
||||
@@ -7,6 +8,6 @@ import { AppointmentsService } from './appointments.service';
|
||||
@Module({
|
||||
imports: [StaffModule],
|
||||
controllers: [AppointmentsController],
|
||||
providers: [AppointmentsService, PrismaService],
|
||||
providers: [AppointmentsService, PrismaService, ClinicOrgGuard],
|
||||
})
|
||||
export class AppointmentsModule {}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
UpdateLanguageDto,
|
||||
} from './dto/update-language.dto';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
import { ownerPermissionsForOrgType, type OrganizationTypeName } from '../../common/organization-type';
|
||||
|
||||
const ALL_PERMISSIONS = [
|
||||
'TAB_TODAY_READ',
|
||||
@@ -34,6 +35,8 @@ const ALL_PERMISSIONS = [
|
||||
'TAB_APPOINTMENTS_EDIT',
|
||||
'TAB_TREATMENT_READ',
|
||||
'TAB_TREATMENT_EDIT',
|
||||
'TAB_CASES_READ',
|
||||
'TAB_CASES_EDIT',
|
||||
'TAB_BILLING_READ',
|
||||
'TAB_BILLING_EDIT',
|
||||
'TAB_REPORTS_READ',
|
||||
@@ -806,11 +809,15 @@ export class AuthService {
|
||||
isOwner: boolean;
|
||||
organization: {
|
||||
plan?: { name: string; maxUsers: number; price: number } | null;
|
||||
type?: { name: string };
|
||||
};
|
||||
permissions?: Array<{ permission: { name: string } }>;
|
||||
}): string[] {
|
||||
if (membership.isOwner) {
|
||||
return membership.organization.plan ? ALL_PERMISSIONS : READ_ONLY_PERMISSIONS;
|
||||
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) || [];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CreatePatientDto } from './dto/create-patient.dto';
|
||||
import { ListPatientsDto } from './dto/list-patients.dto';
|
||||
@@ -18,7 +19,7 @@ import { PatientsService } from './patients.service';
|
||||
|
||||
@ApiTags('patients')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, ClinicOrgGuard)
|
||||
@Controller('patients')
|
||||
export class PatientsController {
|
||||
constructor(private readonly patientsService: PatientsService) {}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||
import { PatientsController } from './patients.controller';
|
||||
import { PatientsService } from './patients.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PatientsController],
|
||||
providers: [PatientsService, PrismaService],
|
||||
providers: [PatientsService, PrismaService, ClinicOrgGuard],
|
||||
})
|
||||
export class PatientsModule {}
|
||||
|
||||
@@ -11,6 +11,10 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
|
||||
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
|
||||
import {
|
||||
filterPermissionsForOrgType,
|
||||
getOrganizationTypeName,
|
||||
} from '../../common/organization-type';
|
||||
import { InviteStaffDto } from './dto/invite-staff.dto';
|
||||
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
|
||||
|
||||
@@ -96,7 +100,8 @@ export class StaffService {
|
||||
}
|
||||
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const normalizedPerms = normalizeTabPermissions(dto.permissionNames);
|
||||
const orgType = await getOrganizationTypeName(this.prisma, organizationId);
|
||||
const normalizedPerms = filterPermissionsForOrgType(dto.permissionNames, orgType);
|
||||
|
||||
const permissionRows = await this.prisma.permission.findMany({
|
||||
where: { name: { in: normalizedPerms } },
|
||||
@@ -371,7 +376,8 @@ export class StaffService {
|
||||
}
|
||||
|
||||
if (dto.permissionNames !== undefined) {
|
||||
const normalizedPerms = normalizeTabPermissions(dto.permissionNames);
|
||||
const orgType = await getOrganizationTypeName(this.prisma, organizationId);
|
||||
const normalizedPerms = filterPermissionsForOrgType(dto.permissionNames, orgType);
|
||||
const permissionRows = await this.prisma.permission.findMany({
|
||||
where: { name: { in: normalizedPerms } },
|
||||
select: { id: true, name: true },
|
||||
|
||||
@@ -17,13 +17,14 @@ import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
|
||||
@ApiTags('treatments')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, ClinicOrgGuard)
|
||||
@Controller('treatments')
|
||||
export class TreatmentsController {
|
||||
constructor(private readonly treatmentsService: TreatmentsService) {}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||
import { TreatmentsController } from './treatments.controller';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TreatmentsController],
|
||||
providers: [TreatmentsService, PrismaService],
|
||||
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
|
||||
})
|
||||
export class TreatmentsModule {}
|
||||
|
||||
Reference in New Issue
Block a user