"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.listMenuItems = listMenuItems; exports.getMenuItem = getMenuItem; exports.createMenuItem = createMenuItem; exports.updateMenuItem = updateMenuItem; exports.setMenuItemStatus = setMenuItemStatus; exports.deleteMenuItem = deleteMenuItem; exports.assignMenuItemToPlans = assignMenuItemToPlans; exports.removeMenuItemFromPlan = removeMenuItemFromPlan; exports.assignMenuItemToCompanies = assignMenuItemToCompanies; exports.removeMenuItemFromCompany = removeMenuItemFromCompany; exports.previewCompanyMenu = previewCompanyMenu; exports.getEmployeeMenu = getEmployeeMenu; exports.listMenuAuditLogs = listMenuAuditLogs; const errors_1 = require("../../http/errors"); const prisma_1 = require("../../lib/prisma"); const EMPLOYEE_ROLES = ['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) { if (typeof value !== 'string') return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function sortByDisplayOrder(items) { return [...items].sort((a, b) => { if (a.displayOrder !== b.displayOrder) return a.displayOrder - b.displayOrder; return (a.label ?? '').localeCompare(b.label ?? ''); }); } function toPlanValue(plan) { return plan; } function toItemTypeValue(itemType) { return itemType; } function describeEntitlement(plan, hasSubscriptionAssignment, hasCompanyAssignment, defaultsToAllSubscriptions) { 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, action, resource, resourceId, before, after) { await prisma_1.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, menuItemId) { const label = input.label.trim(); if (!label) throw new errors_1.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 errors_1.AppError('This menu item type cannot define a route or URL.', 400, 'validation_error'); } } else if (!routeOrUrl) { throw new errors_1.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 errors_1.AppError('External links must use HTTPS.', 400, 'validation_error'); } } if (routeOrUrl && itemType === 'INTERNAL_PAGE' && !routeOrUrl.startsWith('/')) { throw new errors_1.AppError('Internal page routes must start with "/".', 400, 'validation_error'); } if (parentId) { if (menuItemId && parentId === menuItemId) { throw new errors_1.AppError('A menu item cannot be its own parent.', 400, 'validation_error'); } const parent = await prisma_1.prisma.menuItem.findUnique({ where: { id: parentId } }); if (!parent) throw new errors_1.AppError('Parent menu item was not found.', 400, 'validation_error'); if (parent.itemType !== 'PARENT_MENU') { throw new errors_1.AppError('Only parent menu items can contain children.', 400, 'validation_error'); } } if (routeOrUrl && !MENU_ITEM_TYPES_WITHOUT_ROUTE.has(itemType)) { const duplicate = await prisma_1.prisma.menuItem.findFirst({ where: { routeOrUrl, parentId, ...(menuItemId ? { id: { not: menuItemId } } : {}), }, select: { id: true }, }); if (duplicate) { throw new errors_1.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 errors_1.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_1.prisma.company.findMany({ where: { id: { in: companyIds }, status: { not: 'CANCELLED' } }, select: { id: true }, }); if (companies.length !== companyIds.length) { throw new errors_1.AppError('Menu items can only be assigned to active companies.', 400, 'validation_error'); } } } async function syncMenuAssignments(tx, menuItemId, input) { 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) { return prisma_1.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' }], }, }, }); } async function listMenuItems() { return prisma_1.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' }], }); } async function getMenuItem(id) { return loadMenuItemDetail(id); } async function createMenuItem(input, admin) { await assertMenuItemPayload(input); const created = await prisma_1.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, menuItem.id, input); return menuItem; }); const detail = await loadMenuItemDetail(created.id); await createAuditLog(admin, 'CREATE_MENU_ITEM', 'MenuItem', created.id, undefined, detail); return detail; } async function updateMenuItem(id, input, admin) { const before = await loadMenuItemDetail(id); await assertMenuItemPayload(input, id); const updated = await prisma_1.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, id, input); return menuItem; }); const detail = await loadMenuItemDetail(updated.id); await createAuditLog(admin, 'UPDATE_MENU_ITEM', 'MenuItem', id, before, detail); return detail; } async function setMenuItemStatus(id, isActive, admin) { const before = await prisma_1.prisma.menuItem.findUniqueOrThrow({ where: { id } }); if (!isActive && before.isRequired && admin.role !== 'SUPER_ADMIN') { throw new errors_1.AppError('Required system items can only be disabled by a Super Admin.', 403, 'forbidden'); } const updated = await prisma_1.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; } async function deleteMenuItem(id, admin) { const before = await loadMenuItemDetail(id); if (before.isRequired && admin.role !== 'SUPER_ADMIN') { throw new errors_1.AppError('Required system items can only be deleted by a Super Admin.', 403, 'forbidden'); } await prisma_1.prisma.menuItem.delete({ where: { id } }); await createAuditLog(admin, 'DELETE_MENU_ITEM', 'MenuItem', id, before, undefined); } async function assignMenuItemToPlans(id, assignments, admin) { await prisma_1.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_1.prisma.subscriptionMenuItem.deleteMany({ where: { menuItemId: id, plan: { in: payload.map((assignment) => assignment.plan) } }, }); if (payload.length > 0) { await prisma_1.prisma.subscriptionMenuItem.createMany({ data: payload }); } await createAuditLog(admin, 'ASSIGN_MENU_ITEM_TO_SUBSCRIPTIONS', 'MenuSubscriptionAssignment', id, undefined, payload); return loadMenuItemDetail(id); } async function removeMenuItemFromPlan(id, plan, admin) { await prisma_1.prisma.subscriptionMenuItem.deleteMany({ where: { menuItemId: id, plan: toPlanValue(plan), }, }); await createAuditLog(admin, 'REMOVE_MENU_ITEM_FROM_SUBSCRIPTION', 'MenuSubscriptionAssignment', id, { plan }, undefined); return loadMenuItemDetail(id); } async function assignMenuItemToCompanies(id, assignments, admin) { await prisma_1.prisma.menuItem.findUniqueOrThrow({ where: { id } }); const companyIds = assignments.map((assignment) => assignment.companyId); const companies = await prisma_1.prisma.company.findMany({ where: { id: { in: companyIds }, status: { not: 'CANCELLED' } }, select: { id: true }, }); if (companies.length !== new Set(companyIds).size) { throw new errors_1.AppError('Menu items can only be assigned to active companies.', 400, 'validation_error'); } await prisma_1.prisma.companyMenuItem.deleteMany({ where: { menuItemId: id, companyId: { in: companyIds }, }, }); if (assignments.length > 0) { await prisma_1.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); } async function removeMenuItemFromCompany(id, companyId, admin) { await prisma_1.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, role, employeeIsActive = true) { const company = await prisma_1.prisma.company.findUniqueOrThrow({ where: { id: companyId }, select: { id: true, name: true, status: true, subscription: { select: { plan: true, status: true, }, }, }, }); const menuItems = await prisma_1.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; 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 = []; 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) { const byId = new Map(); const roots = []; 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) => { 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; } async function previewCompanyMenu(input) { 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, }))), }; } async function getEmployeeMenu(employeeId) { const employee = await prisma_1.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, }))), }; } async function listMenuAuditLogs(query) { const where = { resource: { in: MENU_AUDIT_RESOURCES, }, }; const [data, total] = await Promise.all([ prisma_1.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_1.prisma.auditLog.count({ where }), ]); return { data, total, page: query.page, pageSize: query.pageSize, totalPages: Math.ceil(total / query.pageSize), }; } //# sourceMappingURL=menu.service.js.map