diff --git a/apps/api/src/modules/menu/menu.service.test.ts b/apps/api/src/modules/menu/menu.service.test.ts index ca9086b..fd1e279 100644 --- a/apps/api/src/modules/menu/menu.service.test.ts +++ b/apps/api/src/modules/menu/menu.service.test.ts @@ -309,4 +309,69 @@ describe('menu.service', () => { expect(result.subscriptionAccessLevel).toBe('none') expect(result.items).toEqual([]) }) + + it('falls back to the baseline feature menu when full-access entitlements only expose dashboard and subscription', async () => { + vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({ + id: 'employee_1', + role: 'OWNER', + isActive: true, + companyId: 'company_1', + } as never) + + vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ + id: 'company_1', + name: 'Atlas Cars', + status: 'TRIALING', + subscription: { plan: 'STARTER', status: 'TRIALING' }, + } as never) + + vi.mocked(prisma.menuItem.findMany).mockResolvedValue([ + { + id: 'item_dashboard', + systemKey: 'dashboard', + label: 'Dashboard', + itemType: 'INTERNAL_PAGE', + routeOrUrl: '/', + icon: 'LayoutDashboard', + parentId: null, + openInNewTab: false, + isRequired: true, + isActive: true, + displayOrder: 10, + roleVisibilities: [{ role: 'OWNER' }], + subscriptionAssignments: [{ plan: 'STARTER', displayOrder: 10, isActive: true }], + companyAssignments: [], + }, + { + id: 'item_subscription', + systemKey: 'subscription', + label: 'Subscription', + itemType: 'INTERNAL_PAGE', + routeOrUrl: '/subscription', + icon: 'CreditCard', + parentId: null, + openInNewTab: false, + isRequired: false, + isActive: true, + displayOrder: 999, + roleVisibilities: [{ role: 'OWNER' }], + subscriptionAssignments: [], + companyAssignments: [], + }, + ] as never) + + const result = await getEmployeeMenu('employee_1') + + expect(result.subscriptionStatus).toBe('TRIALING') + expect(result.subscriptionAccessLevel).toBe('full') + expect(result.items.map((menuItem) => menuItem.systemKey)).toEqual([ + 'dashboard', + 'reservations', + 'fleet', + 'customers', + 'reports', + 'billing', + 'settings', + ]) + }) }) diff --git a/apps/api/src/modules/menu/menu.service.ts b/apps/api/src/modules/menu/menu.service.ts index cad7450..af52fd5 100644 --- a/apps/api/src/modules/menu/menu.service.ts +++ b/apps/api/src/modules/menu/menu.service.ts @@ -69,6 +69,17 @@ const MENU_ITEM_TYPES_WITHOUT_ROUTE = new Set([ 'SECTION_LABEL', 'DIVIDER', ]) +const ROLE_RANK: Record = { OWNER: 3, MANAGER: 2, AGENT: 1 } +const BASELINE_EMPLOYEE_MENU_ITEMS = [ + { systemKey: 'dashboard', label: 'Dashboard', routeOrUrl: '/', icon: 'LayoutDashboard', displayOrder: 10, minRole: 'AGENT' }, + { systemKey: 'reservations', label: 'Reservations', routeOrUrl: '/reservations', icon: 'Calendar', displayOrder: 20, minRole: 'AGENT' }, + { systemKey: 'fleet', label: 'Fleet', routeOrUrl: '/fleet', icon: 'Car', displayOrder: 30, minRole: 'AGENT' }, + { systemKey: 'customers', label: 'Customers', routeOrUrl: '/customers', icon: 'Users', displayOrder: 40, minRole: 'AGENT' }, + { systemKey: 'reports', label: 'Reports', routeOrUrl: '/reports', icon: 'BarChart2', displayOrder: 50, minRole: 'MANAGER' }, + { systemKey: 'billing', label: 'Billing', routeOrUrl: '/billing', icon: 'CreditCard', displayOrder: 60, minRole: 'MANAGER' }, + { systemKey: 'settings', label: 'Settings', routeOrUrl: '/settings', icon: 'Settings', displayOrder: 70, minRole: 'OWNER' }, +] as const +const MENU_RECOVERY_ROUTES = new Set(['/subscription', '/subscription/success', '/subscription/cancel']) function normalizeNullableString(value?: string | null) { if (typeof value !== 'string') return null @@ -83,6 +94,10 @@ function sortByDisplayOrder= ROLE_RANK[minRole] +} + function toPlanValue(plan: Plan) { return plan } @@ -579,6 +594,32 @@ function buildMenuTree(items: Array<{ return roots } +function hasFeatureMenuRoute(items: Array<{ itemType: MenuItemType; routeOrUrl: string | null }>) { + return items.some((item) => ( + item.itemType === 'INTERNAL_PAGE' && + item.routeOrUrl && + item.routeOrUrl !== '/' && + !MENU_RECOVERY_ROUTES.has(item.routeOrUrl) + )) +} + +function buildBaselineEmployeeMenu(role: EmployeeRole) { + return BASELINE_EMPLOYEE_MENU_ITEMS + .filter((item) => hasMinRole(role, item.minRole)) + .map((item) => ({ + id: `baseline_${item.systemKey}`, + systemKey: item.systemKey, + label: item.label, + itemType: 'INTERNAL_PAGE' as MenuItemType, + routeOrUrl: item.routeOrUrl, + icon: item.icon, + parentId: null, + openInNewTab: false, + displayOrder: item.displayOrder, + children: [], + })) +} + export async function previewCompanyMenu(input: MenuPreviewInput) { const context = await getMenuEvaluationContext(input.companyId, input.role) return { @@ -619,6 +660,23 @@ export async function getEmployeeMenu(employeeId: string) { const context = await getMenuEvaluationContext(employee.companyId, employee.role, employee.isActive) const visibleItems = context.items.filter((item) => item.visible) + const menuItems = buildMenuTree( + visibleItems.map((item) => ({ + id: item.id, + systemKey: item.systemKey, + label: item.label, + itemType: item.itemType, + routeOrUrl: item.routeOrUrl, + icon: item.icon, + parentId: item.parentId, + openInNewTab: item.openInNewTab, + displayOrder: item.displayOrder, + })), + ) + const resolvedItems = + context.subscriptionAccessLevel === 'full' && employee.isActive && !hasFeatureMenuRoute(menuItems) + ? buildBaselineEmployeeMenu(employee.role) + : menuItems return { companyId: employee.companyId, @@ -626,19 +684,7 @@ export async function getEmployeeMenu(employeeId: string) { subscriptionPlan: context.currentPlan, subscriptionStatus: context.subscriptionStatus, subscriptionAccessLevel: context.subscriptionAccessLevel, - items: buildMenuTree( - visibleItems.map((item) => ({ - id: item.id, - systemKey: item.systemKey, - label: item.label, - itemType: item.itemType, - routeOrUrl: item.routeOrUrl, - icon: item.icon, - parentId: item.parentId, - openInNewTab: item.openInNewTab, - displayOrder: item.displayOrder, - })), - ), + items: resolvedItems, } }