add editable menu items for companies

This commit is contained in:
root
2026-06-03 20:06:41 -04:00
parent e6110d7faa
commit e0b2239f66
13 changed files with 2881 additions and 5 deletions
@@ -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) => {
@@ -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),
})