diff --git a/backend/prisma/migrations/20260628130000_add_cases_permissions/migration.sql b/backend/prisma/migrations/20260628130000_add_cases_permissions/migration.sql new file mode 100644 index 0000000..e44243d --- /dev/null +++ b/backend/prisma/migrations/20260628130000_add_cases_permissions/migration.sql @@ -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; diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 6023967..171ac79 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -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'], diff --git a/backend/src/common/guards/clinic-org.guard.ts b/backend/src/common/guards/clinic-org.guard.ts new file mode 100644 index 0000000..5b16b09 --- /dev/null +++ b/backend/src/common/guards/clinic-org.guard.ts @@ -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 { + 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; + } +} diff --git a/backend/src/common/organization-type.ts b/backend/src/common/organization-type.ts new file mode 100644 index 0000000..d8b59ce --- /dev/null +++ b/backend/src/common/organization-type.ts @@ -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([ + 'TAB_PATIENTS_READ', + 'TAB_PATIENTS_EDIT', + 'TAB_APPOINTMENTS_READ', + 'TAB_APPOINTMENTS_EDIT', + 'TAB_TREATMENT_READ', + 'TAB_TREATMENT_EDIT', +]); + +const LAB_ONLY_PERMISSIONS = new Set(['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(CLINIC_TAB_PERMISSIONS); +const LAB_TAB_SET = new Set(LAB_TAB_PERMISSIONS); + +export function permissionsAllowedForOrgType(orgType: OrganizationTypeName): Set { + 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 { + 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 { + 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 { + const type = await getOrganizationTypeName(prisma, organizationId); + if (type !== 'LAB') { + throw new ForbiddenException('This action is only available for lab organizations'); + } +} diff --git a/backend/src/common/permissions.ts b/backend/src/common/permissions.ts index 5d0b172..4a6f6c8 100644 --- a/backend/src/common/permissions.ts +++ b/backend/src/common/permissions.ts @@ -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 = { 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', }; diff --git a/backend/src/modules/appointments/appointments.controller.ts b/backend/src/modules/appointments/appointments.controller.ts index f6dfa7f..12a50f2 100644 --- a/backend/src/modules/appointments/appointments.controller.ts +++ b/backend/src/modules/appointments/appointments.controller.ts @@ -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) {} diff --git a/backend/src/modules/appointments/appointments.module.ts b/backend/src/modules/appointments/appointments.module.ts index e6f0b5f..e51d71f 100644 --- a/backend/src/modules/appointments/appointments.module.ts +++ b/backend/src/modules/appointments/appointments.module.ts @@ -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 {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 17fcac4..5e1b861 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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) || []; } diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts index 46be5c1..cd6fe86 100644 --- a/backend/src/modules/patients/patients.controller.ts +++ b/backend/src/modules/patients/patients.controller.ts @@ -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) {} diff --git a/backend/src/modules/patients/patients.module.ts b/backend/src/modules/patients/patients.module.ts index 514afd3..1f1a53d 100644 --- a/backend/src/modules/patients/patients.module.ts +++ b/backend/src/modules/patients/patients.module.ts @@ -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 {} diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index 60f4b23..eb476b8 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -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 }, diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts index 6f9bdac..7293618 100644 --- a/backend/src/modules/treatments/treatments.controller.ts +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -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) {} diff --git a/backend/src/modules/treatments/treatments.module.ts b/backend/src/modules/treatments/treatments.module.ts index 52fb3b6..47646b1 100644 --- a/backend/src/modules/treatments/treatments.module.ts +++ b/backend/src/modules/treatments/treatments.module.ts @@ -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 {} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3ed3e7c..4cb3bf1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 3de25cc..31f11d1 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -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": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 789e111..20ef812 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -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.", diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx new file mode 100644 index 0000000..1495096 --- /dev/null +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +export default function CasesPage() { + const t = useTranslations('cases'); + + return ( +
+

{t('title')}

+

{t('stubDescription')}

+
+ ); +} diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx index d3b4c39..246abcd 100644 --- a/frontend/src/app/[locale]/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -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]); diff --git a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx index 73a0171..e89a731 100644 --- a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -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 (
- {STAFF_FEATURE_GROUPS.map((g) => { + {staffFeatureGroupsForOrgType(organizationType).map((g) => { const cell = state[g.edit] ?? { read: false, edit: false }; return (
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); diff --git a/frontend/src/components/shared/permissions.ts b/frontend/src/components/shared/permissions.ts index 210bf25..c35867a 100644 --- a/frontend/src/components/shared/permissions.ts +++ b/frontend/src/components/shared/permissions.ts @@ -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'); +} diff --git a/frontend/src/components/staff/staff-permission-form.ts b/frontend/src/components/staff/staff-permission-form.ts index a14ff02..e4312d7 100644 --- a/frontend/src/components/staff/staff-permission-form.ts +++ b/frontend/src/components/staff/staff-permission-form.ts @@ -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; -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; diff --git a/frontend/src/components/ui/shared/Sidebar.tsx b/frontend/src/components/ui/shared/Sidebar.tsx index 9faf4e5..d4e2e68 100644 --- a/frontend/src/components/ui/shared/Sidebar.tsx +++ b/frontend/src/components/ui/shared/Sidebar.tsx @@ -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 (