doxa-maps-cdn/server/api/admin/category-order.patch.ts
2026-05-21 13:28:12 +01:00

69 lines
2.7 KiB
TypeScript

// Admin: apply a new menu_order to a set of sibling categories. The
// request body is the drag-and-drop order the admin committed in the
// UI: `{ parentId: string | null, categoryIds: [firstId, ...] }`.
// `parentId` scopes the move — null reorders the root level, otherwise
// it reorders the children of that parent. The handler writes index
// positions into `categories.menu_order` in a single transaction, then
// purges the affected subtree's cached pages (the sidebar nav tree and
// the parent landing-page grid both reflect category order).
//
// Lives at /api/admin/category-order rather than under categories/ on
// purpose: a static sibling of categories/[id] would make the typed
// $fetch for /api/admin/categories/${id} ambiguous and narrow its
// allowed methods. This mirrors page-order, which sidesteps that by
// living under the [id] segment — reorder can't (parentId may be null).
import { defineEventHandler, readBody, createError } from 'h3'
import { requirePermission } from '../../utils/rbac'
import { reorderCategories } from '../../database/categories'
import { purgeCmsCategory } from '../../utils/cmsCache'
import { logUpdate } from '../../utils/activity-logger'
export default defineEventHandler(async (event) => {
await requirePermission(event, 'pages.write')
const body = await readBody<{ parentId?: unknown; categoryIds?: unknown }>(event)
if (!Array.isArray(body?.categoryIds)) {
throw createError({ statusCode: 400, statusMessage: 'categoryIds array required' })
}
const parentId =
body.parentId === undefined || body.parentId === null
? null
: String(body.parentId)
const categoryIds = body.categoryIds
.map(v => (typeof v === 'string' ? v : null))
.filter((v): v is string => Boolean(v))
if (categoryIds.length === 0) {
throw createError({ statusCode: 400, statusMessage: 'categoryIds cannot be empty' })
}
try {
await reorderCategories(parentId, categoryIds)
} catch (e: any) {
if (e?.statusCode) {
throw createError({ statusCode: e.statusCode, statusMessage: e.message })
}
throw e
}
// Category order feeds the per-page sidebar (shared across the whole
// top-level subtree) and the parent's landing-page grid. purgeCmsCategory
// walks up to the top-level ancestor and purges that entire subtree, so
// one call per affected top-level tree covers it. Reordering children of
// a parent shares one tree; reordering roots touches each root's tree.
if (parentId) {
await purgeCmsCategory(parentId)
} else {
await Promise.all(categoryIds.map(id => purgeCmsCategory(id)))
}
logUpdate('categories', parentId ?? 'root', event, {
event: 'reorder',
parentId,
categoryCount: categoryIds.length
})
return { ok: true, categoryIds }
})