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