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,15 @@
-- Add Cases tab permissions for lab organizations
INSERT INTO "features" ("id", "name", "description", "organizationTypeId")
VALUES (gen_random_uuid(), 'Cases', 'Lab cases inbox', NULL)
ON CONFLICT ("name") DO NOTHING;
INSERT INTO "permissions" ("id", "name", "description", "featureId")
SELECT gen_random_uuid(), v.name, NULL, f.id
FROM (VALUES
('TAB_CASES_READ'),
('TAB_CASES_EDIT')
) AS v(name)
CROSS JOIN "features" f
WHERE f.name = 'Cases'
ON CONFLICT ("name") DO NOTHING;

View File

@@ -89,6 +89,10 @@ async function main() {
name: 'Treatment',
permissions: ['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'],
},
{
name: 'Cases',
permissions: ['TAB_CASES_READ', 'TAB_CASES_EDIT'],
},
{
name: 'Billing',
permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'],

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

View File

@@ -51,6 +51,7 @@
"patients": "Patients",
"appointment": "Appointment",
"treatment": "Treatment",
"cases": "Cases",
"billing": "Billing",
"reports": "Reports",
"clinics": "Clinics",
@@ -261,6 +262,7 @@
"featurePatients": "Patients",
"featureAppointment": "Appointment",
"featureTreatment": "Treatment",
"featureCases": "Cases",
"featureBilling": "Billing",
"featureReports": "Reports",
"noTabAccess": "No tab access",
@@ -313,6 +315,10 @@
"statusInactive": "Inactive",
"emptyValue": "-"
},
"cases": {
"title": "Cases",
"stubDescription": "Received lab cases from linked clinics will appear here. Full inbox and task workflow coming in a later phase."
},
"appointments": {
"title": "Appointments",
"subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.",

View File

@@ -51,6 +51,7 @@
"patients": "بیماران",
"appointment": "نوبت‌ها",
"treatment": "درمان",
"cases": "پرونده‌ها",
"billing": "صورتحساب",
"reports": "گزارش‌ها",
"clinics": "کلینیک‌ها",
@@ -261,6 +262,7 @@
"featurePatients": "بیماران",
"featureAppointment": "نوبت‌ها",
"featureTreatment": "درمان",
"featureCases": "پرونده‌ها",
"featureBilling": "صورتحساب",
"featureReports": "گزارش‌ها",
"noTabAccess": "دسترسی به برگه‌ها وجود ندارد",
@@ -313,6 +315,10 @@
"statusInactive": "غیرفعال",
"emptyValue": "-"
},
"cases": {
"title": "پرونده‌ها",
"stubDescription": "پرونده‌های دریافتی از کلینیک‌های متصل به زودی اینجا نمایش داده می‌شوند. صندوق ورودی کامل و گردش کار وظایف در فاز بعدی اضافه می‌شود."
},
"appointments": {
"title": "نوبت‌ها",
"subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.",

View File

@@ -51,6 +51,7 @@
"patients": "Patiënten",
"appointment": "Afspraak",
"treatment": "Behandeling",
"cases": "Dossiers",
"billing": "Facturatie",
"reports": "Rapporten",
"clinics": "Klinieken",
@@ -261,6 +262,7 @@
"featurePatients": "Patiënten",
"featureAppointment": "Afspraak",
"featureTreatment": "Behandeling",
"featureCases": "Dossiers",
"featureBilling": "Facturatie",
"featureReports": "Rapporten",
"noTabAccess": "Geen tabbladtoegang",
@@ -313,6 +315,10 @@
"statusInactive": "Inactief",
"emptyValue": "-"
},
"cases": {
"title": "Dossiers",
"stubDescription": "Ontvangen labdossiers van gekoppelde klinieken verschijnen hier. Volledige inbox en takenworkflow volgen in een latere fase."
},
"appointments": {
"title": "Afspraken",
"subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.",

View File

@@ -0,0 +1,14 @@
'use client';
import { useTranslations } from 'next-intl';
export default function CasesPage() {
const t = useTranslations('cases');
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-muted max-w-xl">{t('stubDescription')}</p>
</div>
);
}

View File

@@ -8,10 +8,8 @@ import Sidebar from '@/components/ui/shared/Sidebar';
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
import {
canAccessAppointmentsSection,
canAccessDashboardRoute,
firstAccessibleDashboardPath,
getRequiredReadPermissionForPath,
hasPermission,
} from '@/components/shared/permissions';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
@@ -33,15 +31,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return;
}
const required = getRequiredReadPermissionForPath(pathname);
if (required) {
const allowed =
hasPermission(currentOrganization, required) ||
(required === 'TAB_APPOINTMENTS_READ' &&
canAccessAppointmentsSection(currentOrganization));
if (!allowed) {
router.replace(firstAccessibleDashboardPath(currentOrganization));
}
if (!canAccessDashboardRoute(currentOrganization, pathname)) {
router.replace(firstAccessibleDashboardPath(currentOrganization));
}
}, [isAuthReady, user, currentOrganization, router, pathname]);

View File

@@ -9,13 +9,13 @@ import {
canViewStaff,
} from '@/components/shared/permissions';
import {
STAFF_FEATURE_GROUPS,
permissionNamesFromFeatureState,
emptyFeaturePermissionState,
featureStateFromPermissionNames,
featureStateHasTreatmentEdit,
resolveStaffFeatureLabel,
formatAccessSummary,
staffFeatureGroupsForOrgType,
type FeaturePermState,
} from '@/components/staff/staff-permission-form';
import {
@@ -112,7 +112,7 @@ function PermissionGrid({
return (
<div className="grid gap-3 sm:grid-cols-2">
{STAFF_FEATURE_GROUPS.map((g) => {
{staffFeatureGroupsForOrgType(organizationType).map((g) => {
const cell = state[g.edit] ?? { read: false, edit: false };
return (
<div
@@ -199,10 +199,14 @@ export default function StaffPage() {
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
const inviteHasTreatmentEdit = useMemo(
() => featureStateHasTreatmentEdit(invitePerms),
[invitePerms],
() =>
currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(invitePerms),
[currentOrganization?.type, invitePerms],
);
const editHasTreatmentEdit = useMemo(
() => currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(editPerms),
[currentOrganization?.type, editPerms],
);
const editHasTreatmentEdit = useMemo(() => featureStateHasTreatmentEdit(editPerms), [editPerms]);
const hasActivePlan = Boolean(currentOrganization?.plan);
const atSeatLimit = useMemo(() => {
if (!seats || seats.unlimited) return false;
@@ -309,7 +313,7 @@ export default function StaffPage() {
setInviteStep(1);
setInviteEmail('');
setInviteName('');
setInvitePerms(emptyFeaturePermissionState());
setInvitePerms(emptyFeaturePermissionState(currentOrganization?.type));
const defaults = createDefaultWorkingHoursState();
setInviteWorkingHoursDays(defaults.days);
setInviteAutoRepeatWeekly(defaults.autoRepeatWeekly);
@@ -393,7 +397,7 @@ export default function StaffPage() {
setEditing(m);
setEditStep(1);
setEditName(m.name);
setEditPerms(featureStateFromPermissionNames(m.permissions ?? []));
setEditPerms(featureStateFromPermissionNames(m.permissions ?? [], currentOrganization?.type));
setEditHoursValidationError(null);
const defaults = createDefaultWorkingHoursState();
setEditWorkingHoursDays(defaults.days);

View File

@@ -1,16 +1,34 @@
import type { Organization } from '@/types/organization';
const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [
{ prefix: '/today', permission: 'TAB_TODAY_READ' },
{ prefix: '/staff', permission: 'TAB_STAFF_READ' },
{ prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ' },
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ' },
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' },
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ' },
{ prefix: '/billing', permission: 'TAB_BILLING_READ' },
{ prefix: '/reports', permission: 'TAB_REPORTS_READ' },
export type OrgTypeName = 'CLINIC' | 'LAB';
export type DashboardRouteConfig = {
prefix: string;
permission: string;
orgTypes: OrgTypeName[];
};
export const DASHBOARD_ROUTES: DashboardRouteConfig[] = [
{ prefix: '/today', permission: 'TAB_TODAY_READ', orgTypes: ['CLINIC', 'LAB'] },
{ prefix: '/staff', permission: 'TAB_STAFF_READ', orgTypes: ['CLINIC', 'LAB'] },
{ prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ', orgTypes: ['CLINIC', 'LAB'] },
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ', orgTypes: ['CLINIC'] },
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] },
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] },
{ prefix: '/cases', permission: 'TAB_CASES_READ', orgTypes: ['LAB'] },
{ prefix: '/billing', permission: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] },
{ prefix: '/reports', permission: 'TAB_REPORTS_READ', orgTypes: ['CLINIC', 'LAB'] },
];
export function isRouteAllowedForOrgType(pathname: string, orgType: OrgTypeName | undefined): boolean {
if (!orgType) return false;
const route = DASHBOARD_ROUTES.find(
(r) => pathname === r.prefix || pathname.startsWith(`${r.prefix}/`),
);
if (!route) return true;
return route.orgTypes.includes(orgType);
}
export function hasPermission(org: Organization | null, permission: string): boolean {
if (!org) return false;
return Boolean(org.permissions?.includes(permission));
@@ -26,21 +44,49 @@ export function canViewTab(org: Organization | null, readPermission: string): bo
return hasPermission(org, readPermission);
}
export function getRequiredReadPermissionForPath(pathname: string): string | null {
for (const { prefix, permission } of ROUTE_TAB_READ) {
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
return permission;
export function getRouteConfigForPath(pathname: string): DashboardRouteConfig | null {
for (const route of DASHBOARD_ROUTES) {
if (pathname === route.prefix || pathname.startsWith(`${route.prefix}/`)) {
return route;
}
}
return null;
}
export function getRequiredReadPermissionForPath(pathname: string): string | null {
return getRouteConfigForPath(pathname)?.permission ?? null;
}
export function canAccessDashboardRoute(org: Organization | null, pathname: string): boolean {
if (!org) return false;
const route = getRouteConfigForPath(pathname);
if (!route) return true;
if (!isRouteAllowedForOrgType(pathname, org.type)) {
return false;
}
if (route.prefix === '/appointments') {
return canAccessAppointmentsSection(org);
}
return hasPermission(org, route.permission);
}
/** First dashboard route the user may open (ordered). Fallback: account settings. */
export function firstAccessibleDashboardPath(org: Organization | null): string {
if (!org) return '/today';
for (const { prefix, permission } of ROUTE_TAB_READ) {
if (hasPermission(org, permission)) return prefix;
for (const route of DASHBOARD_ROUTES) {
if (!route.orgTypes.includes(org.type)) continue;
if (route.prefix === '/appointments') {
if (canAccessAppointmentsSection(org)) return route.prefix;
continue;
}
if (hasPermission(org, route.permission)) return route.prefix;
}
return '/settings/account';
}
@@ -62,6 +108,9 @@ export function canEditAppointments(org: Organization | null): boolean {
if (!org) {
return false;
}
if (org.type !== 'CLINIC') {
return false;
}
if (org.isOwner) {
return true;
}
@@ -76,6 +125,9 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean
if (!org) {
return false;
}
if (org.type !== 'CLINIC') {
return false;
}
if (org.isOwner) {
return true;
}
@@ -90,6 +142,7 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean
/** Treatment composer, scheduling columns, and saving clinical workflows */
export function canEditTreatment(org: Organization | null): boolean {
if (!org) return false;
if (org.type !== 'CLINIC') return false;
if (org.isOwner) return true;
return hasPermission(org, 'TAB_TREATMENT_EDIT');
}
@@ -97,9 +150,28 @@ export function canEditTreatment(org: Organization | null): boolean {
/** View treatment workspace (read-only or edit) */
export function canViewTreatment(org: Organization | null): boolean {
if (!org) return false;
if (org.type !== 'CLINIC') return false;
if (org.isOwner) return true;
return (
hasPermission(org, 'TAB_TREATMENT_READ') ||
hasPermission(org, 'TAB_TREATMENT_EDIT')
);
}
/** Lab cases inbox */
export function canViewCases(org: Organization | null): boolean {
if (!org) return false;
if (org.type !== 'LAB') return false;
if (org.isOwner) return true;
return (
hasPermission(org, 'TAB_CASES_READ') ||
hasPermission(org, 'TAB_CASES_EDIT')
);
}
export function canEditCases(org: Organization | null): boolean {
if (!org) return false;
if (org.type !== 'LAB') return false;
if (org.isOwner) return true;
return hasPermission(org, 'TAB_CASES_EDIT');
}

View File

@@ -3,22 +3,32 @@
* Add presentational pieces under ./components/ as the UI grows.
*/
import type { OrgTypeName } from '@/components/shared/permissions';
export const STAFF_FEATURE_GROUPS = [
{ labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' },
{ labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
{ labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
{ labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
{ labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
{ labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
{ labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT', orgTypes: ['CLINIC'] as const },
{ labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT', orgTypes: ['CLINIC'] as const },
{ labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT', orgTypes: ['CLINIC'] as const },
{ labelKey: 'featureCases', read: 'TAB_CASES_READ', edit: 'TAB_CASES_EDIT', orgTypes: ['LAB'] as const },
{ labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
{ labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
] as const;
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
export type OrgType = 'CLINIC' | 'LAB' | null | undefined;
export type OrgType = OrgTypeName | null | undefined;
type StaffFeaturesTranslate = (key: string) => string;
export function staffFeatureGroupsForOrgType(organizationType: OrgType) {
if (!organizationType) return [...STAFF_FEATURE_GROUPS];
return STAFF_FEATURE_GROUPS.filter((g) =>
(g.orgTypes as readonly OrgTypeName[]).includes(organizationType),
);
}
export function resolveStaffFeatureLabel(
group: (typeof STAFF_FEATURE_GROUPS)[number],
organizationType: OrgType,
@@ -30,18 +40,21 @@ export function resolveStaffFeatureLabel(
return t(group.labelKey);
}
export function emptyFeaturePermissionState(): FeaturePermState {
export function emptyFeaturePermissionState(organizationType?: OrgType): FeaturePermState {
const s: FeaturePermState = {};
for (const g of STAFF_FEATURE_GROUPS) {
for (const g of staffFeatureGroupsForOrgType(organizationType)) {
s[g.edit] = { read: false, edit: false };
}
return s;
}
export function featureStateFromPermissionNames(names: string[]): FeaturePermState {
export function featureStateFromPermissionNames(
names: string[],
organizationType?: OrgType,
): FeaturePermState {
const set = new Set(names);
const s = emptyFeaturePermissionState();
for (const g of STAFF_FEATURE_GROUPS) {
const s = emptyFeaturePermissionState(organizationType);
for (const g of staffFeatureGroupsForOrgType(organizationType)) {
const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit;
s[g.edit] = { read: hasRead, edit: hasEdit };
@@ -73,7 +86,7 @@ export function formatAccessSummary(
if (!permissionNames?.length) return t('noTabAccess');
const set = new Set(permissionNames);
const parts: string[] = [];
for (const g of STAFF_FEATURE_GROUPS) {
for (const g of staffFeatureGroupsForOrgType(organizationType)) {
const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit;
if (!hasRead) continue;

View File

@@ -11,51 +11,73 @@ import {
FlaskConical,
FileText,
CreditCard,
Package,
} from 'lucide-react';
import type { OrgTypeName } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount';
import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions';
import {
canAccessAppointmentsSection,
canViewCases,
canViewTab,
} from '@/components/shared/permissions';
import {
counterpartOrganizationType,
organizationTypeIcon,
} from '@/components/shared/organizationTypeIcon';
type MenuItem = {
name: string;
path: string;
icon: typeof LayoutDashboard;
read: string;
orgTypes: OrgTypeName[];
};
function Sidebar() {
const t = useTranslations('nav');
const tCommon = useTranslations('common');
const pathname = usePathname();
const { currentOrganization } = useAuth();
const pendingConnectionsCount = usePendingConnectionsCount();
const orgType = currentOrganization?.type;
const menu = useMemo(
() => [
{ name: t('dashboard'), path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
{ name: t('staff'), path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
const menu = useMemo((): MenuItem[] => {
const items: MenuItem[] = [
{ name: t('dashboard'), path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ', orgTypes: ['CLINIC', 'LAB'] },
{ name: t('staff'), path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ', orgTypes: ['CLINIC', 'LAB'] },
{
name: currentOrganization?.type === 'LAB' ? t('clinics') : t('labs'),
name: orgType === 'LAB' ? t('clinics') : t('labs'),
path: '/organizations',
icon: organizationTypeIcon(counterpartOrganizationType(currentOrganization?.type)),
read: 'TAB_ORGANIZATIONS_READ' as const,
icon: organizationTypeIcon(counterpartOrganizationType(orgType)),
read: 'TAB_ORGANIZATIONS_READ',
orgTypes: ['CLINIC', 'LAB'],
},
{ name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
{ name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
{ name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const },
{ name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const },
{ name: t('reports'), path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const },
],
[currentOrganization?.type, t],
);
{ name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ', orgTypes: ['CLINIC'] },
{ name: t('cases'), path: '/cases', icon: Package, read: 'TAB_CASES_READ', orgTypes: ['LAB'] },
{ name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] },
{ name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] },
{ name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] },
{ name: t('reports'), path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ', orgTypes: ['CLINIC', 'LAB'] },
];
return items;
}, [orgType, t]);
const visibleMenu = useMemo(
() =>
menu.filter((item) => {
if (!orgType || !item.orgTypes.includes(orgType)) {
return false;
}
if (item.path === '/appointments') {
return canAccessAppointmentsSection(currentOrganization);
}
if (item.path === '/cases') {
return canViewCases(currentOrganization);
}
return canViewTab(currentOrganization, item.read);
}),
[currentOrganization, menu],
[currentOrganization, menu, orgType],
);
return (