feature: phase1 - org-type navigation, Cases permissions, staff filtering, and route guards.

This commit is contained in:
2026-06-28 14:59:06 +03:30
parent 64c7e5a257
commit dc965b2528
22 changed files with 376 additions and 76 deletions

View 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;
}
}

View 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');
}
}

View File

@@ -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',
};

View File

@@ -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) {}

View File

@@ -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 {}

View File

@@ -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) || [];
}

View File

@@ -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) {}

View File

@@ -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 {}

View File

@@ -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 },

View File

@@ -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) {}

View File

@@ -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 {}