From e0b2239f662cf788a6070846d91956568e038353 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 3 Jun 2026 20:06:41 -0400 Subject: [PATCH] add editable menu items for companies --- apps/admin/src/app/dashboard/layout.tsx | 1 + .../app/dashboard/menu-management/page.tsx | 700 ++++++++++++++++++ apps/admin/src/components/I18nProvider.tsx | 3 + apps/api/src/modules/admin/admin.routes.ts | 89 +++ apps/api/src/modules/admin/admin.schemas.ts | 72 ++ .../src/modules/auth/auth.employee.routes.ts | 7 + .../api/src/modules/menu/menu.service.test.ts | 201 +++++ apps/api/src/modules/menu/menu.service.ts | 642 ++++++++++++++++ .../src/components/layout/Sidebar.tsx | 129 +++- docs/website-admin-menu-management-plan.md | 615 +++++++++++++++ .../migration.sql | 129 ++++ .../migration.sql | 215 ++++++ packages/database/prisma/schema.prisma | 83 +++ 13 files changed, 2881 insertions(+), 5 deletions(-) create mode 100644 apps/admin/src/app/dashboard/menu-management/page.tsx create mode 100644 apps/api/src/modules/menu/menu.service.test.ts create mode 100644 apps/api/src/modules/menu/menu.service.ts create mode 100644 docs/website-admin-menu-management-plan.md create mode 100644 packages/database/prisma/migrations/20260603000000_menu_management/migration.sql create mode 100644 packages/database/prisma/migrations/20260603010000_seed_default_sidebar_menu/migration.sql diff --git a/apps/admin/src/app/dashboard/layout.tsx b/apps/admin/src/app/dashboard/layout.tsx index 0361edd..258eb94 100644 --- a/apps/admin/src/app/dashboard/layout.tsx +++ b/apps/admin/src/app/dashboard/layout.tsx @@ -26,6 +26,7 @@ const navLinks = [ { href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' }, { href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' }, { href: '/dashboard/notifications', key: 'notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' }, + { href: '/dashboard/menu-management', key: 'menuManagement', icon: 'M4 6h16M4 12h16M4 18h10' }, { href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' }, { href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' }, { href: '/dashboard/pricing', key: 'pricing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' }, diff --git a/apps/admin/src/app/dashboard/menu-management/page.tsx b/apps/admin/src/app/dashboard/menu-management/page.tsx new file mode 100644 index 0000000..65235fa --- /dev/null +++ b/apps/admin/src/app/dashboard/menu-management/page.tsx @@ -0,0 +1,700 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { ADMIN_API_BASE } from '@/lib/api' + +type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT' +type Plan = 'STARTER' | 'GROWTH' | 'PRO' +type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER' + +type CompanyOption = { + id: string + name: string + subscription?: { plan?: string | null } | null + status: string +} + +type MenuItem = { + id: string + systemKey: string | null + label: string + itemType: MenuItemType + routeOrUrl: string | null + icon: string | null + parentId: string | null + displayOrder: number + openInNewTab: boolean + isRequired: boolean + isActive: boolean + parent?: { id: string; label: string } | null + roleVisibilities: Array<{ role: EmployeeRole }> + subscriptionAssignments: Array<{ plan: Plan; displayOrder: number; isActive: boolean }> + companyAssignments: Array<{ + companyId: string + displayOrder: number + isActive: boolean + company: { id: string; name: string } + }> +} + +type AuditLog = { + id: string + action: string + resource: string + resourceId: string | null + createdAt: string + adminUser: { firstName: string; lastName: string; email: string } | null +} + +type PreviewItem = { + id: string + label: string + systemKey: string | null + itemType: MenuItemType + routeOrUrl: string | null + displayOrder: number + visible: boolean + source: 'subscription' | 'company' | 'none' + reasons: string[] +} + +type PreviewResponse = { + company: { id: string; name: string; status: string; subscription: { plan: Plan; status: string } | null } + role: EmployeeRole + subscriptionPlan: Plan | null + items: PreviewItem[] +} + +type FormState = { + label: string + systemKey: string + itemType: MenuItemType + routeOrUrl: string + icon: string + parentId: string + displayOrder: string + openInNewTab: boolean + isRequired: boolean + isActive: boolean + roles: EmployeeRole[] + plans: Plan[] + companyIds: string[] +} + +const ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT'] +const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO'] +const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'] + +const INPUT = + 'mt-1 w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-blue-950 outline-none focus:border-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white' +const LABEL = 'text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400' + +function getToken() { + return typeof window === 'undefined' ? '' : localStorage.getItem('admin_token') ?? '' +} + +async function api(path: string, options?: RequestInit): Promise { + const token = getToken() + const res = await fetch(`${ADMIN_API_BASE}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + ...(options?.headers ?? {}), + }, + }) + + const json = await res.json() + if (!res.ok) throw new Error(json?.message ?? 'Request failed') + return (json?.data ?? json) as T +} + +function emptyForm(): FormState { + return { + label: '', + systemKey: '', + itemType: 'INTERNAL_PAGE', + routeOrUrl: '', + icon: '', + parentId: '', + displayOrder: '0', + openInNewTab: false, + isRequired: false, + isActive: true, + roles: ['OWNER', 'MANAGER', 'AGENT'], + plans: ['STARTER', 'GROWTH', 'PRO'], + companyIds: [], + } +} + +function formFromItem(item: MenuItem): FormState { + return { + label: item.label, + systemKey: item.systemKey ?? '', + itemType: item.itemType, + routeOrUrl: item.routeOrUrl ?? '', + icon: item.icon ?? '', + parentId: item.parentId ?? '', + displayOrder: String(item.displayOrder), + openInNewTab: item.openInNewTab, + isRequired: item.isRequired, + isActive: item.isActive, + roles: item.roleVisibilities.map((entry) => entry.role), + plans: item.subscriptionAssignments.map((entry) => entry.plan), + companyIds: item.companyAssignments.map((entry) => entry.companyId), + } +} + +function chip(text: string, tone = 'default') { + const toneClass = + tone === 'success' + ? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-900/20 dark:text-emerald-300' + : tone === 'warn' + ? 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-900/20 dark:text-orange-300' + : 'border-stone-200 bg-stone-50 text-stone-700 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300' + + return ( + + {text} + + ) +} + +export default function MenuManagementPage() { + const [items, setItems] = useState([]) + const [companies, setCompanies] = useState([]) + const [auditLogs, setAuditLogs] = useState([]) + const [preview, setPreview] = useState(null) + const [previewCompanyId, setPreviewCompanyId] = useState('') + const [previewRole, setPreviewRole] = useState('OWNER') + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState(emptyForm()) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [previewing, setPreviewing] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const parentOptions = useMemo( + () => items.filter((item) => item.itemType === 'PARENT_MENU' && item.id !== editingId), + [items, editingId], + ) + + useEffect(() => { + async function load() { + try { + setLoading(true) + setError(null) + const [menuItems, companiesPage, auditPage] = await Promise.all([ + api('/admin/menu-items'), + api<{ data: CompanyOption[] }>('/admin/companies?page=1&pageSize=100'), + api<{ data: AuditLog[] }>('/admin/menu-audit-logs?page=1&pageSize=20'), + ]) + setItems(menuItems) + setCompanies(companiesPage.data) + setAuditLogs(auditPage.data) + if (!previewCompanyId && companiesPage.data[0]?.id) { + setPreviewCompanyId(companiesPage.data[0].id) + } + } catch (err: any) { + setError(err.message ?? 'Failed to load menu management data.') + } finally { + setLoading(false) + } + } + + load() + }, []) + + function updateForm(key: K, value: FormState[K]) { + setForm((prev) => ({ ...prev, [key]: value })) + } + + function toggleArrayValue(key: K, value: FormState[K][number]) { + setForm((prev) => { + const values = prev[key] as string[] + return { + ...prev, + [key]: values.includes(value as string) + ? values.filter((entry) => entry !== value) + : [...values, value as string], + } + }) + } + + async function refreshLists() { + const [menuItems, auditPage] = await Promise.all([ + api('/admin/menu-items'), + api<{ data: AuditLog[] }>('/admin/menu-audit-logs?page=1&pageSize=20'), + ]) + setItems(menuItems) + setAuditLogs(auditPage.data) + } + + async function submitForm(event: React.FormEvent) { + event.preventDefault() + try { + setSaving(true) + setError(null) + setSuccess(null) + + const payload = { + label: form.label, + systemKey: form.systemKey || null, + itemType: form.itemType, + routeOrUrl: form.routeOrUrl || null, + icon: form.icon || null, + parentId: form.parentId || null, + displayOrder: Number(form.displayOrder || 0), + openInNewTab: form.openInNewTab, + isRequired: form.isRequired, + isActive: form.isActive, + roles: form.roles, + subscriptionPlans: form.plans.map((plan) => ({ + plan, + displayOrder: Number(form.displayOrder || 0), + isActive: true, + })), + companyAssignments: form.companyIds.map((companyId) => ({ + companyId, + displayOrder: Number(form.displayOrder || 0), + isActive: true, + })), + } + + if (editingId) { + await api(`/admin/menu-items/${editingId}`, { + method: 'PUT', + body: JSON.stringify(payload), + }) + setSuccess('Menu item updated.') + } else { + await api('/admin/menu-items', { + method: 'POST', + body: JSON.stringify(payload), + }) + setSuccess('Menu item created.') + } + + setEditingId(null) + setForm(emptyForm()) + await refreshLists() + } catch (err: any) { + setError(err.message ?? 'Failed to save menu item.') + } finally { + setSaving(false) + } + } + + function startCreate() { + setEditingId(null) + setForm(emptyForm()) + setSuccess(null) + setError(null) + } + + function startEdit(item: MenuItem) { + setEditingId(item.id) + setForm(formFromItem(item)) + setSuccess(null) + setError(null) + } + + async function toggleStatus(item: MenuItem) { + try { + setError(null) + await api(`/admin/menu-items/${item.id}/status`, { + method: 'PATCH', + body: JSON.stringify({ isActive: !item.isActive }), + }) + await refreshLists() + } catch (err: any) { + setError(err.message ?? 'Failed to update status.') + } + } + + async function removeItem(item: MenuItem) { + if (!window.confirm(`Delete "${item.label}"?`)) return + try { + setError(null) + await api(`/admin/menu-items/${item.id}`, { method: 'DELETE' }) + if (editingId === item.id) { + setEditingId(null) + setForm(emptyForm()) + } + await refreshLists() + } catch (err: any) { + setError(err.message ?? 'Failed to delete menu item.') + } + } + + async function runPreview() { + if (!previewCompanyId) return + try { + setPreviewing(true) + setError(null) + const result = await api('/admin/menu-preview', { + method: 'POST', + body: JSON.stringify({ companyId: previewCompanyId, role: previewRole }), + }) + setPreview(result) + } catch (err: any) { + setError(err.message ?? 'Failed to load menu preview.') + } finally { + setPreviewing(false) + } + } + + if (loading) { + return ( +
+
+ Loading menu management… +
+
+ ) + } + + return ( +
+
+
+
+

Platform Navigation

+

Menu Management

+

+ Control company dashboard navigation by plan, role, and company-specific exceptions from one admin surface. +

+
+ +
+
+ + {(error || success) && ( +
+ {error &&
{error}
} + {success &&
{success}
} +
+ )} + +
+
+
+
+

Menu items

+

{items.length} configured items

+
+
+ +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
ItemTypeAssignmentsStatusActions
+
+
+ {item.label} + {item.isRequired && chip('Required', 'warn')} + {item.systemKey && chip(item.systemKey)} +
+

+ {item.routeOrUrl || 'No route'} • order {item.displayOrder} + {item.parent ? ` • child of ${item.parent.label}` : ''} +

+
+
{item.itemType} +
+
+ {item.subscriptionAssignments.length > 0 + ? item.subscriptionAssignments.map((assignment) => ( + + {assignment.plan} + + )) + : No plan assignments} +
+
+ {item.companyAssignments.slice(0, 3).map((assignment) => ( + + {assignment.company.name} + + ))} + {item.companyAssignments.length > 3 && chip(`+${item.companyAssignments.length - 3} more`)} +
+
+ {item.roleVisibilities.map((entry) => ( + + {entry.role} + + ))} +
+
+
+ {item.isActive ? chip('Active', 'success') : chip('Inactive')} + +
+ + + +
+
+
+
+ +
+
+
+

{editingId ? 'Edit menu item' : 'New menu item'}

+

+ Configure menu metadata, role visibility, plan defaults, and company-specific overrides. +

+
+ {editingId && ( + + )} +
+ +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ + +
+
+ + + +
+

Role visibility

+
+ {ROLES.map((role) => ( + + ))} +
+
+ +
+

Subscription plans

+
+ {PLANS.map((plan) => ( + + ))} +
+
+ +
+

Company-specific assignments

+
+ {companies.map((company) => ( + + ))} +
+
+ + +
+
+
+ +
+
+
+
+

Preview company menu

+

+ Test the generated menu for a company and role before troubleshooting or saving follow-up changes. +

+
+
+ + +
+
+ + +
+ +
+ + {preview && ( +
+
+

{preview.company.name}

+

+ Plan: {preview.subscriptionPlan ?? 'None'} • Role: {preview.role} • Company status: {preview.company.status} +

+
+ +
+ {preview.items.map((item) => ( +
+
+ {item.label} + {item.visible ? chip('Visible', 'success') : chip('Hidden')} + {chip(item.source === 'none' ? 'unassigned' : item.source)} +
+

+ {item.routeOrUrl || 'No route'} • order {item.displayOrder} +

+
    + {item.reasons.map((reason, index) => ( +
  • {reason}
  • + ))} +
+
+ ))} +
+
+ )} +
+ +
+

Recent menu audit activity

+

+ Recent platform-side changes to menu items and assignments. +

+ +
+ {auditLogs.map((entry) => ( +
+
+

{entry.action}

+ + {new Date(entry.createdAt).toLocaleString()} + +
+

+ {entry.adminUser + ? `${entry.adminUser.firstName} ${entry.adminUser.lastName} • ${entry.adminUser.email}` + : 'Unknown admin'} +

+
+ ))} +
+
+
+
+ ) +} diff --git a/apps/admin/src/components/I18nProvider.tsx b/apps/admin/src/components/I18nProvider.tsx index 9c7d0b6..5b2e301 100644 --- a/apps/admin/src/components/I18nProvider.tsx +++ b/apps/admin/src/components/I18nProvider.tsx @@ -30,6 +30,7 @@ const dictionaries: Record = { billing: 'Billing', pricing: 'Pricing', notifications: 'Notifications', + menuManagement: 'Menu Management', }, logout: 'Logout', language: 'Language', @@ -52,6 +53,7 @@ const dictionaries: Record = { billing: 'Facturation', pricing: 'Tarification', notifications: 'Notifications', + menuManagement: 'Gestion du menu', }, logout: 'Déconnexion', language: 'Langue', @@ -74,6 +76,7 @@ const dictionaries: Record = { billing: 'الفوترة', pricing: 'الأسعار', notifications: 'الإشعارات', + menuManagement: 'إدارة القوائم', }, logout: 'تسجيل الخروج', language: 'اللغة', diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index 7769cea..5cb379b 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -4,6 +4,7 @@ import { parseBody, parseQuery, parseParams } from '../../http/validate' import { ok, created } from '../../http/respond' import * as service from './admin.service' import * as subService from '../subscriptions/subscription.service' +import * as menuService from '../menu/menu.service' import { presentAdminUser } from './admin.presenter' import { loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema, @@ -16,6 +17,8 @@ import { promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema, billingAccountUpdateSchema, createBillingInvoiceSchema, payBillingInvoiceSchema, retryBillingInvoiceSchema, billingReasonSchema, billingCreditNoteSchema, billingRefundSchema, + menuItemSchema, menuItemStatusSchema, menuPlanAssignmentsSchema, menuCompanyAssignmentsSchema, + menuPreviewSchema, menuAuditLogQuerySchema, menuPlanParamSchema, menuCompanyParamSchema, } from './admin.schemas' import { z } from 'zod' @@ -127,6 +130,92 @@ router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('AD } catch (err) { next(err) } }) +// ─── Menu management ────────────────────────────────────────── + +router.get('/menu-items', requireAdminAuth, requireAdminRole('SUPPORT'), async (_req, res, next) => { + try { + ok(res, await menuService.listMenuItems()) + } catch (err) { next(err) } +}) + +router.post('/menu-items', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { + try { + created(res, { data: await menuService.createMenuItem(parseBody(menuItemSchema, req), req.admin) }) + } catch (err) { next(err) } +}) + +router.get('/menu-items/:id', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + const { id } = parseParams(idParamSchema, req) + ok(res, await menuService.getMenuItem(id)) + } catch (err) { next(err) } +}) + +router.put('/menu-items/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { + try { + const { id } = parseParams(idParamSchema, req) + ok(res, await menuService.updateMenuItem(id, parseBody(menuItemSchema, req), req.admin)) + } catch (err) { next(err) } +}) + +router.patch('/menu-items/:id/status', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { + try { + const { id } = parseParams(idParamSchema, req) + const { isActive } = parseBody(menuItemStatusSchema, req) + ok(res, await menuService.setMenuItemStatus(id, isActive, req.admin)) + } catch (err) { next(err) } +}) + +router.delete('/menu-items/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { + try { + const { id } = parseParams(idParamSchema, req) + await menuService.deleteMenuItem(id, req.admin) + ok(res, { success: true }) + } catch (err) { next(err) } +}) + +router.post('/menu-items/:id/subscriptions', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { + try { + const { id } = parseParams(idParamSchema, req) + const { assignments } = parseBody(menuPlanAssignmentsSchema, req) + ok(res, await menuService.assignMenuItemToPlans(id, assignments, req.admin)) + } catch (err) { next(err) } +}) + +router.delete('/menu-items/:id/subscriptions/:plan', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { + try { + const { id, plan } = parseParams(menuPlanParamSchema, req) + ok(res, await menuService.removeMenuItemFromPlan(id, plan, req.admin)) + } catch (err) { next(err) } +}) + +router.post('/menu-items/:id/companies', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { + try { + const { id } = parseParams(idParamSchema, req) + const { assignments } = parseBody(menuCompanyAssignmentsSchema, req) + ok(res, await menuService.assignMenuItemToCompanies(id, assignments, req.admin)) + } catch (err) { next(err) } +}) + +router.delete('/menu-items/:id/companies/:companyId', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => { + try { + const { id, companyId } = parseParams(menuCompanyParamSchema, req) + ok(res, await menuService.removeMenuItemFromCompany(id, companyId, req.admin)) + } catch (err) { next(err) } +}) + +router.post('/menu-preview', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + ok(res, await menuService.previewCompanyMenu(parseBody(menuPreviewSchema, req))) + } catch (err) { next(err) } +}) + +router.get('/menu-audit-logs', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => { + try { + ok(res, await menuService.listMenuAuditLogs(parseQuery(menuAuditLogQuerySchema, req))) + } catch (err) { next(err) } +}) + // ─── Renters ─────────────────────────────────────────────────── router.get('/renters', requireAdminAuth, async (req, res, next) => { diff --git a/apps/api/src/modules/admin/admin.schemas.ts b/apps/api/src/modules/admin/admin.schemas.ts index 9777895..d4960a5 100644 --- a/apps/api/src/modules/admin/admin.schemas.ts +++ b/apps/api/src/modules/admin/admin.schemas.ts @@ -332,3 +332,75 @@ export const promotionUpdateSchema = promotionCreateSchema.partial() export const planFeatureIdParamSchema = z.object({ featureId: z.string().min(1) }) export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) }) + +const employeeRoleSchema = z.enum(['OWNER', 'MANAGER', 'AGENT']) +const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO']) +const menuItemTypeSchema = z.enum(['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']) + +export const menuItemSchema = z.object({ + systemKey: z.string().trim().min(1).max(120).optional().nullable(), + label: z.string().trim().min(1).max(120), + itemType: menuItemTypeSchema, + routeOrUrl: z.string().trim().max(400).optional().nullable(), + icon: z.string().trim().max(120).optional().nullable(), + parentId: z.string().trim().min(1).optional().nullable(), + displayOrder: z.number().int().min(0).default(0), + openInNewTab: z.boolean().default(false), + isRequired: z.boolean().default(false), + isActive: z.boolean().default(true), + roles: z.array(employeeRoleSchema).default([]), + subscriptionPlans: z.array(z.object({ + plan: planSchema, + displayOrder: z.number().int().min(0).optional(), + isActive: z.boolean().optional(), + })).default([]), + companyAssignments: z.array(z.object({ + companyId: z.string().trim().min(1), + displayOrder: z.number().int().min(0).optional(), + isActive: z.boolean().optional(), + })).default([]), +}) + +export const menuItemStatusSchema = z.object({ + isActive: z.boolean(), +}) + +export const menuPlanAssignmentsSchema = z.object({ + assignments: z.array(z.object({ + plan: planSchema, + displayOrder: z.number().int().min(0).optional(), + isActive: z.boolean().optional(), + })).min(1), +}) + +export const menuCompanyAssignmentsSchema = z.object({ + assignments: z.array(z.object({ + companyId: z.string().trim().min(1), + displayOrder: z.number().int().min(0).optional(), + isActive: z.boolean().optional(), + })).min(1), +}) + +export const menuPreviewSchema = z.object({ + companyId: z.string().trim().min(1), + role: employeeRoleSchema, +}) + +export const menuAuditLogQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), +}) + +export const menuItemIdParamSchema = z.object({ + id: z.string().min(1), +}) + +export const menuPlanParamSchema = z.object({ + id: z.string().min(1), + plan: planSchema, +}) + +export const menuCompanyParamSchema = z.object({ + id: z.string().min(1), + companyId: z.string().min(1), +}) diff --git a/apps/api/src/modules/auth/auth.employee.routes.ts b/apps/api/src/modules/auth/auth.employee.routes.ts index 5cdaf02..9110d95 100644 --- a/apps/api/src/modules/auth/auth.employee.routes.ts +++ b/apps/api/src/modules/auth/auth.employee.routes.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { requireCompanyAuth } from '../../middleware/requireCompanyAuth' import { parseBody } from '../../http/validate' import { ok } from '../../http/respond' +import { getEmployeeMenu } from '../menu/menu.service' import { employeeForgotPasswordSchema, employeeLanguageSchema, @@ -18,6 +19,12 @@ router.get('/me', requireCompanyAuth, async (req, res, next) => { } catch (err) { next(err) } }) +router.get('/menu', requireCompanyAuth, async (req, res, next) => { + try { + ok(res, await getEmployeeMenu(req.employee.id)) + } catch (err) { next(err) } +}) + router.post('/login', async (req, res, next) => { try { const body = parseBody(employeeLoginSchema, req) diff --git a/apps/api/src/modules/menu/menu.service.test.ts b/apps/api/src/modules/menu/menu.service.test.ts new file mode 100644 index 0000000..e81f918 --- /dev/null +++ b/apps/api/src/modules/menu/menu.service.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../lib/prisma', () => ({ + prisma: { + menuItem: { + findMany: vi.fn(), + }, + employee: { + findUniqueOrThrow: vi.fn(), + }, + company: { + findUniqueOrThrow: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '../../lib/prisma' +import { getEmployeeMenu, previewCompanyMenu } from './menu.service' + +describe('menu.service', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('builds the employee menu from subscription defaults and company overrides', async () => { + vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({ + id: 'employee_1', + role: 'MANAGER', + isActive: true, + companyId: 'company_1', + } as never) + + vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ + id: 'company_1', + name: 'Atlas Cars', + status: 'ACTIVE', + subscription: { plan: 'GROWTH', status: 'ACTIVE' }, + } 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: 1, + roleVisibilities: [{ role: 'MANAGER' }], + subscriptionAssignments: [], + companyAssignments: [], + }, + { + id: 'item_reports', + systemKey: 'reports', + label: 'Reports', + itemType: 'INTERNAL_PAGE', + routeOrUrl: '/reports', + icon: 'BarChart2', + parentId: null, + openInNewTab: false, + isRequired: false, + isActive: true, + displayOrder: 80, + roleVisibilities: [{ role: 'MANAGER' }], + subscriptionAssignments: [{ plan: 'GROWTH', displayOrder: 80, isActive: true }], + companyAssignments: [], + }, + { + id: 'item_finance', + systemKey: null, + label: 'Finance Portal', + itemType: 'EXTERNAL_LINK', + routeOrUrl: 'https://finance.example.com', + icon: null, + parentId: null, + openInNewTab: true, + isRequired: false, + isActive: true, + displayOrder: 200, + roleVisibilities: [{ role: 'MANAGER' }], + subscriptionAssignments: [], + companyAssignments: [{ companyId: 'company_1', displayOrder: 5, isActive: true }], + }, + { + id: 'item_owner_only', + systemKey: 'settings', + label: 'Settings', + itemType: 'INTERNAL_PAGE', + routeOrUrl: '/settings', + icon: 'Settings', + parentId: null, + openInNewTab: false, + isRequired: false, + isActive: true, + displayOrder: 150, + roleVisibilities: [{ role: 'OWNER' }], + subscriptionAssignments: [{ plan: 'GROWTH', displayOrder: 150, isActive: true }], + companyAssignments: [], + }, + ] as never) + + const result = await getEmployeeMenu('employee_1') + + expect(result.items).toHaveLength(3) + expect(result.items.map((item) => item.label)).toEqual(['Dashboard', 'Finance Portal', 'Reports']) + expect(result.items[0]).toMatchObject({ + label: 'Dashboard', + itemType: 'INTERNAL_PAGE', + }) + expect(result.items[1]).toMatchObject({ + label: 'Finance Portal', + itemType: 'EXTERNAL_LINK', + openInNewTab: true, + }) + }) + + it('explains hidden items in preview results', async () => { + vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ + id: 'company_1', + name: 'Atlas Cars', + status: 'ACTIVE', + subscription: { plan: 'STARTER', status: 'ACTIVE' }, + } 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: 1, + roleVisibilities: [{ role: 'MANAGER' }], + subscriptionAssignments: [], + companyAssignments: [], + }, + { + id: 'item_reports', + systemKey: 'reports', + label: 'Reports', + itemType: 'INTERNAL_PAGE', + routeOrUrl: '/reports', + icon: 'BarChart2', + parentId: null, + openInNewTab: false, + isRequired: false, + isActive: true, + displayOrder: 80, + roleVisibilities: [{ role: 'MANAGER' }], + subscriptionAssignments: [{ plan: 'GROWTH', displayOrder: 80, isActive: true }], + companyAssignments: [], + }, + { + id: 'item_direct', + systemKey: null, + label: 'Finance Portal', + itemType: 'EXTERNAL_LINK', + routeOrUrl: 'https://finance.example.com', + icon: null, + parentId: null, + openInNewTab: true, + isRequired: false, + isActive: true, + displayOrder: 20, + roleVisibilities: [{ role: 'MANAGER' }], + subscriptionAssignments: [], + companyAssignments: [{ companyId: 'company_1', displayOrder: 10, isActive: true }], + }, + ] as never) + + const result = await previewCompanyMenu({ companyId: 'company_1', role: 'MANAGER' }) + + expect(result.items.find((item) => item.label === 'Dashboard')).toMatchObject({ + visible: true, + source: 'subscription', + }) + expect(result.items.find((item) => item.label === 'Dashboard')?.reasons.join(' ')).toContain('defaults to all subscription plans') + expect(result.items.find((item) => item.label === 'Finance Portal')).toMatchObject({ + visible: true, + source: 'company', + }) + expect(result.items.find((item) => item.label === 'Reports')).toMatchObject({ + visible: false, + }) + expect(result.items.find((item) => item.label === 'Reports')?.reasons.join(' ')).toContain('STARTER does not include this menu item') + }) +}) diff --git a/apps/api/src/modules/menu/menu.service.ts b/apps/api/src/modules/menu/menu.service.ts new file mode 100644 index 0000000..f108029 --- /dev/null +++ b/apps/api/src/modules/menu/menu.service.ts @@ -0,0 +1,642 @@ +import { AppError } from '../../http/errors' +import { prisma } from '../../lib/prisma' + +type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT' +type Plan = 'STARTER' | 'GROWTH' | 'PRO' +type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER' + +type AdminActor = { + id: string + role: string +} + +type MenuItemInput = { + systemKey?: string | null + label: string + itemType: MenuItemType + routeOrUrl?: string | null + icon?: string | null + parentId?: string | null + displayOrder?: number + openInNewTab?: boolean + isRequired?: boolean + isActive?: boolean + roles?: EmployeeRole[] + subscriptionPlans?: Array<{ + plan: Plan + displayOrder?: number + isActive?: boolean + }> + companyAssignments?: Array<{ + companyId: string + displayOrder?: number + isActive?: boolean + }> +} + +type MenuPreviewInput = { + companyId: string + role: EmployeeRole +} + +type MenuAuditQuery = { + page: number + pageSize: number +} + +const EMPLOYEE_ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT'] +const MENU_AUDIT_RESOURCES = ['MenuItem', 'MenuSubscriptionAssignment', 'MenuCompanyAssignment', 'MenuRoleVisibility'] +const MENU_ITEM_TYPES_WITHOUT_ROUTE = new Set([ + 'PARENT_MENU', + 'SECTION_LABEL', + 'DIVIDER', +]) + +function normalizeNullableString(value?: string | null) { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function sortByDisplayOrder(items: T[]) { + return [...items].sort((a, b) => { + if (a.displayOrder !== b.displayOrder) return a.displayOrder - b.displayOrder + return (a.label ?? '').localeCompare(b.label ?? '') + }) +} + +function toPlanValue(plan: Plan) { + return plan +} + +function toItemTypeValue(itemType: MenuItemType) { + return itemType +} + +function describeEntitlement( + plan: Plan | null, + hasSubscriptionAssignment: boolean, + hasCompanyAssignment: boolean, + defaultsToAllSubscriptions: boolean, +) { + if (hasCompanyAssignment) return 'Visible because it is assigned directly to this company.' + if (defaultsToAllSubscriptions) return 'Visible because this menu item defaults to all subscription plans.' + if (hasSubscriptionAssignment && plan) return `Visible because ${plan} includes this menu item.` + if (plan) return `Hidden because ${plan} does not include this menu item.` + return 'Hidden because the company does not have an active subscription assignment for this item.' +} + +async function createAuditLog(admin: AdminActor, action: string, resource: string, resourceId: string | null, before?: unknown, after?: unknown) { + await prisma.auditLog.create({ + data: { + adminUserId: admin.id, + action, + resource, + resourceId, + before: before === undefined ? undefined : JSON.parse(JSON.stringify(before)), + after: after === undefined ? undefined : JSON.parse(JSON.stringify(after)), + }, + }) +} + +async function assertMenuItemPayload(input: MenuItemInput, menuItemId?: string) { + const label = input.label.trim() + if (!label) throw new AppError('Menu label is required.', 400, 'validation_error') + + const itemType = toItemTypeValue(input.itemType) + const routeOrUrl = normalizeNullableString(input.routeOrUrl) + const parentId = normalizeNullableString(input.parentId) + + if (MENU_ITEM_TYPES_WITHOUT_ROUTE.has(itemType)) { + if (routeOrUrl) { + throw new AppError('This menu item type cannot define a route or URL.', 400, 'validation_error') + } + } else if (!routeOrUrl) { + throw new AppError('Route or URL is required for this menu item type.', 400, 'validation_error') + } + + if (routeOrUrl && itemType === 'EXTERNAL_LINK') { + if (!routeOrUrl.startsWith('https://')) { + throw new AppError('External links must use HTTPS.', 400, 'validation_error') + } + } + + if (routeOrUrl && itemType === 'INTERNAL_PAGE' && !routeOrUrl.startsWith('/')) { + throw new AppError('Internal page routes must start with "/".', 400, 'validation_error') + } + + if (parentId) { + if (menuItemId && parentId === menuItemId) { + throw new AppError('A menu item cannot be its own parent.', 400, 'validation_error') + } + + const parent = await prisma.menuItem.findUnique({ where: { id: parentId } }) + if (!parent) throw new AppError('Parent menu item was not found.', 400, 'validation_error') + if (parent.itemType !== 'PARENT_MENU') { + throw new AppError('Only parent menu items can contain children.', 400, 'validation_error') + } + } + + if (routeOrUrl && !MENU_ITEM_TYPES_WITHOUT_ROUTE.has(itemType)) { + const duplicate = await prisma.menuItem.findFirst({ + where: { + routeOrUrl, + parentId, + ...(menuItemId ? { id: { not: menuItemId } } : {}), + }, + select: { id: true }, + }) + + if (duplicate) { + throw new AppError('Duplicate route under the same parent is not allowed.', 409, 'duplicate_menu_route') + } + } + + const roles = Array.from(new Set(input.roles ?? [])) + if (roles.some((role) => !EMPLOYEE_ROLES.includes(role))) { + throw new AppError('One or more role visibility values are invalid.', 400, 'validation_error') + } + + const companyIds = Array.from(new Set((input.companyAssignments ?? []).map((assignment) => assignment.companyId))) + if (companyIds.length > 0) { + const companies = await prisma.company.findMany({ + where: { id: { in: companyIds }, status: { not: 'CANCELLED' } }, + select: { id: true }, + }) + + if (companies.length !== companyIds.length) { + throw new AppError('Menu items can only be assigned to active companies.', 400, 'validation_error') + } + } +} + +async function syncMenuAssignments( + tx: Omit, + menuItemId: string, + input: MenuItemInput, +) { + await tx.menuItemRoleVisibility.deleteMany({ where: { menuItemId } }) + if ((input.roles ?? []).length > 0) { + await tx.menuItemRoleVisibility.createMany({ + data: Array.from(new Set(input.roles)).map((role) => ({ menuItemId, role })), + }) + } + + await tx.subscriptionMenuItem.deleteMany({ where: { menuItemId } }) + if ((input.subscriptionPlans ?? []).length > 0) { + await tx.subscriptionMenuItem.createMany({ + data: input.subscriptionPlans!.map((assignment) => ({ + menuItemId, + plan: toPlanValue(assignment.plan), + displayOrder: assignment.displayOrder ?? input.displayOrder ?? 0, + isActive: assignment.isActive ?? true, + })), + }) + } + + await tx.companyMenuItem.deleteMany({ where: { menuItemId } }) + if ((input.companyAssignments ?? []).length > 0) { + await tx.companyMenuItem.createMany({ + data: input.companyAssignments!.map((assignment) => ({ + menuItemId, + companyId: assignment.companyId, + displayOrder: assignment.displayOrder ?? input.displayOrder ?? 0, + isActive: assignment.isActive ?? true, + })), + }) + } +} + +async function loadMenuItemDetail(id: string) { + return prisma.menuItem.findUniqueOrThrow({ + where: { id }, + include: { + parent: { select: { id: true, label: true } }, + children: { select: { id: true, label: true } }, + roleVisibilities: { orderBy: { role: 'asc' } }, + subscriptionAssignments: { orderBy: [{ plan: 'asc' }, { displayOrder: 'asc' }] }, + companyAssignments: { + include: { company: { select: { id: true, name: true } } }, + orderBy: [{ displayOrder: 'asc' }, { companyId: 'asc' }], + }, + }, + }) +} + +export async function listMenuItems() { + return prisma.menuItem.findMany({ + include: { + parent: { select: { id: true, label: true } }, + roleVisibilities: { orderBy: { role: 'asc' } }, + subscriptionAssignments: { orderBy: [{ plan: 'asc' }, { displayOrder: 'asc' }] }, + companyAssignments: { + include: { company: { select: { id: true, name: true } } }, + orderBy: [{ displayOrder: 'asc' }, { companyId: 'asc' }], + }, + }, + orderBy: [{ displayOrder: 'asc' }, { createdAt: 'asc' }], + }) +} + +export async function getMenuItem(id: string) { + return loadMenuItemDetail(id) +} + +export async function createMenuItem(input: MenuItemInput, admin: AdminActor) { + await assertMenuItemPayload(input) + + const created = await prisma.$transaction(async (tx) => { + const menuItem = await tx.menuItem.create({ + data: { + systemKey: normalizeNullableString(input.systemKey) ?? undefined, + label: input.label.trim(), + itemType: toItemTypeValue(input.itemType), + routeOrUrl: normalizeNullableString(input.routeOrUrl) ?? undefined, + icon: normalizeNullableString(input.icon) ?? undefined, + parentId: normalizeNullableString(input.parentId) ?? undefined, + displayOrder: input.displayOrder ?? 0, + openInNewTab: input.openInNewTab ?? false, + isRequired: input.isRequired ?? false, + isActive: input.isActive ?? true, + createdBy: admin.id, + updatedBy: admin.id, + }, + }) + + await syncMenuAssignments(tx as any, menuItem.id, input) + return menuItem + }) + + const detail = await loadMenuItemDetail(created.id) + await createAuditLog(admin, 'CREATE_MENU_ITEM', 'MenuItem', created.id, undefined, detail) + return detail +} + +export async function updateMenuItem(id: string, input: MenuItemInput, admin: AdminActor) { + const before = await loadMenuItemDetail(id) + await assertMenuItemPayload(input, id) + + const updated = await prisma.$transaction(async (tx) => { + const menuItem = await tx.menuItem.update({ + where: { id }, + data: { + systemKey: normalizeNullableString(input.systemKey) ?? undefined, + label: input.label.trim(), + itemType: toItemTypeValue(input.itemType), + routeOrUrl: normalizeNullableString(input.routeOrUrl) ?? undefined, + icon: normalizeNullableString(input.icon) ?? undefined, + parentId: normalizeNullableString(input.parentId) ?? undefined, + displayOrder: input.displayOrder ?? 0, + openInNewTab: input.openInNewTab ?? false, + isRequired: input.isRequired ?? false, + isActive: input.isActive ?? true, + updatedBy: admin.id, + }, + }) + + await syncMenuAssignments(tx as any, id, input) + return menuItem + }) + + const detail = await loadMenuItemDetail(updated.id) + await createAuditLog(admin, 'UPDATE_MENU_ITEM', 'MenuItem', id, before, detail) + return detail +} + +export async function setMenuItemStatus(id: string, isActive: boolean, admin: AdminActor) { + const before = await prisma.menuItem.findUniqueOrThrow({ where: { id } }) + if (!isActive && before.isRequired && admin.role !== 'SUPER_ADMIN') { + throw new AppError('Required system items can only be disabled by a Super Admin.', 403, 'forbidden') + } + + const updated = await prisma.menuItem.update({ + where: { id }, + data: { isActive, updatedBy: admin.id }, + }) + + await createAuditLog( + admin, + isActive ? 'ENABLE_MENU_ITEM' : 'DISABLE_MENU_ITEM', + 'MenuItem', + id, + { isActive: before.isActive }, + { isActive: updated.isActive }, + ) + + return updated +} + +export async function deleteMenuItem(id: string, admin: AdminActor) { + const before = await loadMenuItemDetail(id) + if (before.isRequired && admin.role !== 'SUPER_ADMIN') { + throw new AppError('Required system items can only be deleted by a Super Admin.', 403, 'forbidden') + } + + await prisma.menuItem.delete({ where: { id } }) + await createAuditLog(admin, 'DELETE_MENU_ITEM', 'MenuItem', id, before, undefined) +} + +export async function assignMenuItemToPlans( + id: string, + assignments: Array<{ plan: Plan; displayOrder?: number; isActive?: boolean }>, + admin: AdminActor, +) { + await prisma.menuItem.findUniqueOrThrow({ where: { id } }) + + const payload = assignments.map((assignment) => ({ + menuItemId: id, + plan: toPlanValue(assignment.plan), + displayOrder: assignment.displayOrder ?? 0, + isActive: assignment.isActive ?? true, + })) + + await prisma.subscriptionMenuItem.deleteMany({ + where: { menuItemId: id, plan: { in: payload.map((assignment) => assignment.plan) } }, + }) + + if (payload.length > 0) { + await prisma.subscriptionMenuItem.createMany({ data: payload }) + } + + await createAuditLog(admin, 'ASSIGN_MENU_ITEM_TO_SUBSCRIPTIONS', 'MenuSubscriptionAssignment', id, undefined, payload) + return loadMenuItemDetail(id) +} + +export async function removeMenuItemFromPlan(id: string, plan: Plan, admin: AdminActor) { + await prisma.subscriptionMenuItem.deleteMany({ + where: { + menuItemId: id, + plan: toPlanValue(plan), + }, + }) + + await createAuditLog(admin, 'REMOVE_MENU_ITEM_FROM_SUBSCRIPTION', 'MenuSubscriptionAssignment', id, { plan }, undefined) + return loadMenuItemDetail(id) +} + +export async function assignMenuItemToCompanies( + id: string, + assignments: Array<{ companyId: string; displayOrder?: number; isActive?: boolean }>, + admin: AdminActor, +) { + await prisma.menuItem.findUniqueOrThrow({ where: { id } }) + const companyIds = assignments.map((assignment) => assignment.companyId) + const companies = await prisma.company.findMany({ + where: { id: { in: companyIds }, status: { not: 'CANCELLED' } }, + select: { id: true }, + }) + + if (companies.length !== new Set(companyIds).size) { + throw new AppError('Menu items can only be assigned to active companies.', 400, 'validation_error') + } + + await prisma.companyMenuItem.deleteMany({ + where: { + menuItemId: id, + companyId: { in: companyIds }, + }, + }) + + if (assignments.length > 0) { + await prisma.companyMenuItem.createMany({ + data: assignments.map((assignment) => ({ + menuItemId: id, + companyId: assignment.companyId, + displayOrder: assignment.displayOrder ?? 0, + isActive: assignment.isActive ?? true, + })), + }) + } + + await createAuditLog(admin, 'ASSIGN_MENU_ITEM_TO_COMPANIES', 'MenuCompanyAssignment', id, undefined, assignments) + return loadMenuItemDetail(id) +} + +export async function removeMenuItemFromCompany(id: string, companyId: string, admin: AdminActor) { + await prisma.companyMenuItem.deleteMany({ + where: { menuItemId: id, companyId }, + }) + + await createAuditLog(admin, 'REMOVE_MENU_ITEM_FROM_COMPANY', 'MenuCompanyAssignment', id, { companyId }, undefined) + return loadMenuItemDetail(id) +} + +async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, employeeIsActive = true) { + const company = await prisma.company.findUniqueOrThrow({ + where: { id: companyId }, + select: { + id: true, + name: true, + status: true, + subscription: { + select: { + plan: true, + status: true, + }, + }, + }, + }) + + const menuItems = await prisma.menuItem.findMany({ + include: { + roleVisibilities: true, + subscriptionAssignments: true, + companyAssignments: { + where: { companyId }, + }, + }, + orderBy: [{ displayOrder: 'asc' }, { createdAt: 'asc' }], + }) + + const currentPlan = company.subscription?.plan ?? null + const currentPlanName = currentPlan as Plan | null + + const evaluated = menuItems.map((item) => { + const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0 + const subscriptionAssignment = item.subscriptionAssignments.find( + (assignment) => assignment.isActive && (!currentPlan || assignment.plan === currentPlan), + ) + const companyAssignment = item.companyAssignments.find((assignment) => assignment.isActive) + const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility) => visibility.role === role) + const subscriptionEntitled = Boolean(subscriptionAssignment || (defaultsToAllSubscriptions && currentPlan)) + const entitled = Boolean(subscriptionEntitled || companyAssignment) + const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive) + const displayOrder = companyAssignment?.displayOrder ?? subscriptionAssignment?.displayOrder ?? item.displayOrder + + const reasons: string[] = [] + if (!item.isActive) reasons.push('Hidden because the menu item is inactive.') + if (!employeeIsActive) reasons.push('Hidden because the user account is inactive.') + if (!entitled) reasons.push(describeEntitlement(currentPlanName, false, false, false)) + if (entitled) { + reasons.push( + describeEntitlement( + currentPlanName, + Boolean(subscriptionAssignment), + Boolean(companyAssignment), + Boolean(defaultsToAllSubscriptions && !companyAssignment), + ), + ) + } + if (!roleAllowed) reasons.push(`Hidden because the ${role} role is not allowed to see this item.`) + + return { + id: item.id, + systemKey: item.systemKey, + label: item.label, + itemType: item.itemType, + routeOrUrl: item.routeOrUrl, + icon: item.icon, + parentId: item.parentId, + openInNewTab: item.openInNewTab, + isRequired: item.isRequired, + isActive: item.isActive, + displayOrder, + source: companyAssignment ? 'company' : subscriptionEntitled ? 'subscription' : 'none', + visible, + reasons, + } + }) + + return { + company, + role, + currentPlan: currentPlanName, + items: sortByDisplayOrder(evaluated), + } +} + +function buildMenuTree(items: Array<{ + id: string + systemKey: string | null + label: string + itemType: MenuItemType + routeOrUrl: string | null + icon: string | null + parentId: string | null + openInNewTab: boolean + displayOrder: number +}>) { + const byId = new Map() + const roots: any[] = [] + + for (const item of items) { + byId.set(item.id, { + 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, + children: [], + }) + } + + for (const item of items) { + const node = byId.get(item.id) + if (item.parentId && byId.has(item.parentId)) { + byId.get(item.parentId).children.push(node) + } else { + roots.push(node) + } + } + + const sortTree = (nodes: any[]) => { + nodes.sort((a, b) => { + if (a.displayOrder !== b.displayOrder) return a.displayOrder - b.displayOrder + return a.label.localeCompare(b.label) + }) + nodes.forEach((node) => sortTree(node.children)) + } + + sortTree(roots) + return roots +} + +export async function previewCompanyMenu(input: MenuPreviewInput) { + const context = await getMenuEvaluationContext(input.companyId, input.role) + return { + company: context.company, + role: context.role, + subscriptionPlan: context.currentPlan, + items: context.items, + finalMenu: buildMenuTree( + context.items + .filter((item) => item.visible) + .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, + })), + ), + } +} + +export async function getEmployeeMenu(employeeId: string) { + const employee = await prisma.employee.findUniqueOrThrow({ + where: { id: employeeId }, + select: { + id: true, + role: true, + isActive: true, + companyId: true, + }, + }) + + const context = await getMenuEvaluationContext(employee.companyId, employee.role, employee.isActive) + const visibleItems = context.items.filter((item) => item.visible) + + return { + companyId: employee.companyId, + role: employee.role, + subscriptionPlan: context.currentPlan, + 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, + })), + ), + } +} + +export async function listMenuAuditLogs(query: MenuAuditQuery) { + const where = { + resource: { + in: MENU_AUDIT_RESOURCES, + }, + } as const + + const [data, total] = await Promise.all([ + prisma.auditLog.findMany({ + where, + include: { adminUser: { select: { firstName: true, lastName: true, email: true } } }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + orderBy: { createdAt: 'desc' }, + }), + prisma.auditLog.count({ where }), + ]) + + return { + data, + total, + page: query.page, + pageSize: query.pageSize, + totalPages: Math.ceil(total / query.pageSize), + } +} diff --git a/apps/dashboard/src/components/layout/Sidebar.tsx b/apps/dashboard/src/components/layout/Sidebar.tsx index 58dbea3..2106355 100644 --- a/apps/dashboard/src/components/layout/Sidebar.tsx +++ b/apps/dashboard/src/components/layout/Sidebar.tsx @@ -1,7 +1,8 @@ 'use client' import Link from 'next/link' -import { usePathname, useRouter } from 'next/navigation' +import { usePathname } from 'next/navigation' +import type { ReactNode } from 'react' import { useEffect, useLayoutEffect, useState } from 'react' import { LayoutDashboard, @@ -54,6 +55,19 @@ interface EmployeeProfile { preferredLanguage?: string } +interface GeneratedMenuItem { + id: string + systemKey: string | null + label: string + itemType: 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER' + routeOrUrl: string | null + icon: string | null + parentId: string | null + openInNewTab: boolean + displayOrder: number + children: GeneratedMenuItem[] +} + function computeInitials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean) if (parts.length === 0) return 'U' @@ -89,6 +103,23 @@ const NAV_ITEMS = [ { href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' }, ] as const +const ICON_MAP = { + LayoutDashboard, + Car, + Calendar, + Globe, + Users, + Tag, + UserPlus, + BarChart2, + CreditCard, + Bell, + Settings, + FileText, + Star, + AlertTriangle, +} as const + const SIDEBAR_BRAND_KEY = 'dashboard_brand' const ROLE_RANK: Record = { OWNER: 3, MANAGER: 2, AGENT: 1 } @@ -107,7 +138,6 @@ export default function Sidebar() { const isRtl = language === 'ar' const pathname = usePathname() const appPath = toDashboardAppPath(pathname) - const router = useRouter() const [brand, setBrand] = useState(null) const [user, setUser] = useState({ displayName: dict.workspaceUser, @@ -115,6 +145,7 @@ export default function Sidebar() { initials: 'W', }) const [role, setRole] = useState('AGENT') + const [menuItems, setMenuItems] = useState(null) const [open, setOpen] = useState(false) const [mounted, setMounted] = useState(false) @@ -192,12 +223,16 @@ export default function Sidebar() { let cancelled = false - apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me') - .then(({ employee }) => { + Promise.all([ + apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me'), + apiFetch<{ items: GeneratedMenuItem[] }>('/auth/employee/menu').catch(() => null), + ]) + .then(([{ employee }, menu]) => { if (cancelled) return window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee)) setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth)) if (employee.role) setRole(employee.role) + if (menu?.items) setMenuItems(menu.items) if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') { // Only apply the server-side preference when the employee has no local preference // stored yet — avoids overriding a language the user manually switched to. @@ -224,6 +259,90 @@ export default function Sidebar() { return appPath.startsWith(item.href) } + const isGeneratedItemActive = (item: GeneratedMenuItem) => { + if (!item.routeOrUrl || item.itemType !== 'INTERNAL_PAGE') return false + if (item.routeOrUrl === '/') return appPath === '/' + return appPath.startsWith(item.routeOrUrl) + } + + const fallbackMenuItems = NAV_ITEMS + .filter((item) => !mounted || hasMinRole(role, item.minRole)) + .map((item) => ({ + id: item.href, + systemKey: item.key, + label: dict.nav[item.key] ?? item.key, + itemType: 'INTERNAL_PAGE' as const, + routeOrUrl: item.href, + icon: item.icon.name, + parentId: null, + openInNewTab: false, + displayOrder: 0, + children: [], + })) + + const resolvedMenuItems = menuItems ?? fallbackMenuItems + + function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode { + return items.map((item) => { + const label = item.systemKey ? (dict.nav[item.systemKey] ?? item.label) : item.label + const paddingClass = depth > 0 ? 'pl-6' : '' + const Icon = item.icon && item.icon in ICON_MAP ? ICON_MAP[item.icon as keyof typeof ICON_MAP] : null + + if (item.itemType === 'DIVIDER') { + return
+ } + + if (item.itemType === 'SECTION_LABEL') { + return ( +

+ {label} +

+ ) + } + + if (item.itemType === 'PARENT_MENU') { + return ( +
+
+ {Icon ? : null} + {label} +
+
{renderGeneratedMenu(item.children, depth + 1)}
+
+ ) + } + + const active = mounted && isGeneratedItemActive(item) + const className = [ + 'flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-colors', + paddingClass, + active ? 'bg-orange-500 text-white' : 'text-slate-400 hover:bg-blue-900/40 hover:text-white', + ].join(' ') + + if (item.itemType === 'EXTERNAL_LINK' && item.routeOrUrl) { + return ( + + {Icon ? : null} + {label} + + ) + } + + return ( + + {Icon ? : null} + {label} + + ) + }) + } + function signOut() { localStorage.removeItem(EMPLOYEE_TOKEN_KEY) localStorage.removeItem(EMPLOYEE_PROFILE_KEY) @@ -280,7 +399,7 @@ export default function Sidebar() {