doxa-maps-cdn/server/database/categories.ts
2026-05-21 13:28:12 +01:00

508 lines
16 KiB
TypeScript

// Query helpers for the `categories` + `category_translations` tables.
// Categories form a tree (`parent_id`) and provide the URL prefix for
// every page beneath them. Pages store only their own leaf slug; the
// full URL is computed at request time by walking the chain.
import { db } from '../utils/database'
import type {
CategoriesTable,
CategoryTranslationsTable,
PagesTable,
PageTranslationsTable
} from './schema'
import type { Selectable } from 'kysely'
import { sql } from 'kysely'
import { purgeCmsPage } from '../utils/cmsCache'
import {
loadCategoryTree,
categoryUrlPath,
pageUrlPath,
descendantCategoryIds
} from './categoryTree'
export type Category = Selectable<CategoriesTable>
export type CategoryTranslation = Selectable<CategoryTranslationsTable>
type Page = Selectable<PagesTable>
type PageTranslation = Selectable<PageTranslationsTable>
export interface CategoryWithTranslations {
category: Category
translations: CategoryTranslation[]
}
export async function listCategories(): Promise<Category[]> {
return db
.selectFrom('categories')
.selectAll()
.orderBy('menu_order', 'asc')
.orderBy('slug', 'asc')
.execute()
}
export async function listCategoriesWithTranslations(): Promise<
Array<Category & { translations: CategoryTranslation[]; page_count: number }>
> {
const categories = await listCategories()
if (categories.length === 0) return []
const ids = categories.map(c => c.id)
const translations = await db
.selectFrom('category_translations')
.selectAll()
.where('category_id', 'in', ids)
.execute()
const counts = await db
.selectFrom('pages')
.select(['category_id', sql<number>`COUNT(*)::int`.as('count')])
.where('category_id', 'in', ids)
.groupBy('category_id')
.execute()
const byCategory = new Map<string, CategoryTranslation[]>()
for (const t of translations) {
const list = byCategory.get(t.category_id) ?? []
list.push(t)
byCategory.set(t.category_id, list)
}
const countByCategory = new Map<string, number>()
for (const c of counts) {
if (c.category_id) countByCategory.set(c.category_id, Number(c.count))
}
return categories.map(c => ({
...c,
translations: byCategory.get(c.id) ?? [],
page_count: countByCategory.get(c.id) ?? 0
}))
}
export async function getCategory(id: string): Promise<CategoryWithTranslations | null> {
const category = await db
.selectFrom('categories')
.selectAll()
.where('id', '=', id)
.executeTakeFirst()
if (!category) return null
const translations = await db
.selectFrom('category_translations')
.selectAll()
.where('category_id', '=', id)
.orderBy('locale', 'asc')
.execute()
return { category, translations }
}
export async function getCategoryName(
categoryId: string,
locale: string,
fallbackLocale = 'en'
): Promise<string | null> {
const preferred = await db
.selectFrom('category_translations')
.select('name')
.where('category_id', '=', categoryId)
.where('locale', '=', locale)
.executeTakeFirst()
if (preferred?.name) return preferred.name
if (locale !== fallbackLocale) {
const fallback = await db
.selectFrom('category_translations')
.select('name')
.where('category_id', '=', categoryId)
.where('locale', '=', fallbackLocale)
.executeTakeFirst()
if (fallback?.name) return fallback.name
}
return null
}
// Sidebar siblings for a category: every page in the category,
// ordered by menu_order then leaf slug.
export async function getCategoryPages(categoryId: string): Promise<Page[]> {
return db
.selectFrom('pages')
.selectAll()
.where('category_id', '=', categoryId)
.orderBy('menu_order', 'asc')
.orderBy('slug', 'asc')
.execute()
}
export async function getCategoryPageTranslations(
categoryId: string,
locale: string,
fallbackLocale = 'en'
): Promise<Array<{ page: Page; translation: PageTranslation }>> {
const pages = await getCategoryPages(categoryId)
if (pages.length === 0) return []
const ids = pages.map(p => p.id)
const preferred = await db
.selectFrom('page_translations')
.selectAll()
.where('page_id', 'in', ids)
.where('locale', '=', locale)
.where('status', '=', 'published')
.execute()
const haveLocale = new Set(preferred.map(p => p.page_id))
const missing = ids.filter(id => !haveLocale.has(id))
let fallbacks: PageTranslation[] = []
if (missing.length && locale !== fallbackLocale) {
fallbacks = await db
.selectFrom('page_translations')
.selectAll()
.where('page_id', 'in', missing)
.where('locale', '=', fallbackLocale)
.where('status', '=', 'published')
.execute()
}
const byPageId = new Map<string, PageTranslation>()
for (const t of preferred) byPageId.set(t.page_id, t)
for (const t of fallbacks) byPageId.set(t.page_id, t)
return pages
.map(page => ({ page, translation: byPageId.get(page.id)! }))
.filter(entry => Boolean(entry.translation))
}
// Public URL resolution for a bare category path (e.g. `/resources`).
// Picks whichever page has the lowest menu_order (ties broken by leaf
// slug) and has at least one published translation, so bare category
// slugs never resolve to something the visitor can't read.
// Set of category ids that directly hold at least one page with a
// published translation in `locale` (or the fallback). Callers combine
// it with `descendantCategoryIds` to decide whether a category subtree
// has any visible pages — e.g. to hide empty categories from the menu
// and the landing-page grid.
export async function categoryIdsWithPublishedPages(
locale: string,
fallbackLocale = 'en'
): Promise<Set<string>> {
const locales = locale === fallbackLocale ? [locale] : [locale, fallbackLocale]
const rows = await db
.selectFrom('pages')
.innerJoin('page_translations', 'page_translations.page_id', 'pages.id')
.select('pages.category_id')
.where('pages.category_id', 'is not', null)
.where('page_translations.status', '=', 'published')
.where('page_translations.locale', 'in', locales)
.distinct()
.execute()
const set = new Set<string>()
for (const r of rows) if (r.category_id) set.add(r.category_id)
return set
}
export async function getCategoryDefaultPage(
categoryId: string,
locale: string,
fallbackLocale = 'en'
): Promise<{ page: Page; translation: PageTranslation } | null> {
const translations = await getCategoryPageTranslations(categoryId, locale, fallbackLocale)
return translations[0] ?? null
}
export interface CreateCategoryInput {
slug: string
parent_id?: string | null
menu_order?: number
translations: Array<{ locale: string; name: string }>
}
export async function createCategory(input: CreateCategoryInput): Promise<Category> {
return db.transaction().execute(async trx => {
const row = await trx
.insertInto('categories')
.values({
slug: input.slug,
parent_id: input.parent_id ?? null,
menu_order: input.menu_order ?? 0
})
.returningAll()
.executeTakeFirstOrThrow()
for (const t of input.translations) {
if (!t.name.trim()) continue
await trx
.insertInto('category_translations')
.values({
category_id: row.id,
locale: t.locale,
name: t.name.trim()
})
.execute()
}
return row
})
}
export interface UpdateCategoryInput {
slug?: string
parent_id?: string | null
menu_order?: number
translations?: Array<{ locale: string; name: string }>
}
// A slug or parent_id change rewrites the URL of every page in the
// category subtree. We compute the *old* URL for each affected page
// before committing, so cache-purge can invalidate the keys that
// existed prior to the change.
export async function updateCategory(
id: string,
input: UpdateCategoryInput
): Promise<{ category: Category; slugsToPurge: string[] }> {
return db.transaction().execute(async trx => {
const existing = await trx
.selectFrom('categories')
.selectAll()
.where('id', '=', id)
.executeTakeFirst()
if (!existing) {
throw Object.assign(new Error('Category not found'), { statusCode: 404 })
}
const slugsToPurge: string[] = []
const structureChanged =
(input.slug !== undefined && input.slug !== existing.slug) ||
(input.parent_id !== undefined && (input.parent_id ?? null) !== existing.parent_id)
// Snapshot the current full URL for every page beneath this
// category before we change anything. After commit the same pages
// will live at new URLs; the old keys still need to be purged.
if (structureChanged) {
const treeBefore = await loadCategoryTree(trx)
const subtreeIds = descendantCategoryIds(treeBefore, id)
const pages = await trx
.selectFrom('pages')
.select(['slug', 'category_id'])
.where('category_id', 'in', subtreeIds)
.execute()
for (const p of pages) {
slugsToPurge.push(pageUrlPath(treeBefore, p))
}
// Also bust the bare-category landing URL for every node in the
// subtree so /resources keeps working after a rename.
for (const cid of subtreeIds) {
const path = categoryUrlPath(treeBefore, cid)
if (path) slugsToPurge.push(path)
}
}
if (input.slug !== undefined && input.slug !== existing.slug) {
await trx
.updateTable('categories')
.set({ slug: input.slug, updated: sql`now()` })
.where('id', '=', id)
.execute()
}
if (input.parent_id !== undefined && (input.parent_id ?? null) !== existing.parent_id) {
await trx
.updateTable('categories')
.set({ parent_id: input.parent_id ?? null, updated: sql`now()` })
.where('id', '=', id)
.execute()
}
if (input.menu_order !== undefined) {
await trx
.updateTable('categories')
.set({ menu_order: input.menu_order, updated: sql`now()` })
.where('id', '=', id)
.execute()
}
if (input.translations) {
let translationsChanged = false
for (const t of input.translations) {
const name = t.name.trim()
const priorRow = await trx
.selectFrom('category_translations')
.select(['id', 'name'])
.where('category_id', '=', id)
.where('locale', '=', t.locale)
.executeTakeFirst()
if (!name) {
if (priorRow) {
await trx
.deleteFrom('category_translations')
.where('id', '=', priorRow.id)
.execute()
translationsChanged = true
}
continue
}
if (priorRow) {
if (priorRow.name !== name) {
await trx
.updateTable('category_translations')
.set({ name, updated: sql`now()` })
.where('id', '=', priorRow.id)
.execute()
translationsChanged = true
}
} else {
await trx
.insertInto('category_translations')
.values({ category_id: id, locale: t.locale, name })
.execute()
translationsChanged = true
}
}
// Every member page's cached response embeds the category's name
// as its `menu_parent.title`. When the name changes in any
// locale, purge every member so readers see the new label.
// Use the post-change tree since the slug/parent (if changed)
// already settled above.
if (translationsChanged) {
const treeAfter = await loadCategoryTree(trx)
const subtreeIds = descendantCategoryIds(treeAfter, id)
const pages = await trx
.selectFrom('pages')
.select(['slug', 'category_id'])
.where('category_id', 'in', subtreeIds)
.execute()
for (const p of pages) {
slugsToPurge.push(pageUrlPath(treeAfter, p))
}
}
}
// The parent category's landing page renders a grid of its child
// categories (each child's url, title, and order). Any edit this
// grid reflects — slug, parent, menu_order, or name — leaves the
// old and new parents' landing pages stale, so purge them too. The
// parents' own paths are unaffected by this category's change, so a
// single post-change tree resolves both correctly.
const parentIdsToPurge = new Set<string>()
if (existing.parent_id) parentIdsToPurge.add(existing.parent_id)
if (input.parent_id) parentIdsToPurge.add(input.parent_id)
if (parentIdsToPurge.size > 0) {
const parentTree = await loadCategoryTree(trx)
for (const pid of parentIdsToPurge) {
const path = categoryUrlPath(parentTree, pid)
if (path) slugsToPurge.push(path)
}
}
const fresh = await trx
.selectFrom('categories')
.selectAll()
.where('id', '=', id)
.executeTakeFirstOrThrow()
return { category: fresh, slugsToPurge }
})
}
export async function deleteCategory(id: string): Promise<void> {
const pageCount = await db
.selectFrom('pages')
.select(sql<number>`COUNT(*)::int`.as('count'))
.where('category_id', '=', id)
.executeTakeFirstOrThrow()
if (pageCount.count > 0) {
throw Object.assign(new Error('Category still has pages'), { statusCode: 409 })
}
const childCount = await db
.selectFrom('categories')
.select(sql<number>`COUNT(*)::int`.as('count'))
.where('parent_id', '=', id)
.executeTakeFirstOrThrow()
if (childCount.count > 0) {
throw Object.assign(new Error('Category still has child categories'), { statusCode: 409 })
}
await db.deleteFrom('categories').where('id', '=', id).execute()
}
// Bulk-reorder pages within a category. Enforces that every passed id
// belongs to the target category so a malformed client payload can't
// reassign menu_order across categories.
export async function reorderCategoryPages(
categoryId: string,
pageIds: string[]
): Promise<void> {
if (pageIds.length === 0) return
await db.transaction().execute(async trx => {
const existing = await trx
.selectFrom('pages')
.select('id')
.where('category_id', '=', categoryId)
.where('id', 'in', pageIds)
.execute()
if (existing.length !== pageIds.length) {
throw Object.assign(
new Error('Some page IDs do not belong to this category'),
{ statusCode: 400 }
)
}
for (let i = 0; i < pageIds.length; i++) {
const pageId = pageIds[i]
if (!pageId) continue
await trx
.updateTable('pages')
.set({ menu_order: i, updated: sql`now()` })
.where('id', '=', pageId)
.execute()
}
})
}
// Bulk-reorder sibling categories under a shared parent (or the root
// level when parentId is null). Enforces that every passed id is
// actually a sibling at that level so a malformed client payload can't
// reshuffle menu_order across the tree or smuggle in a child.
export async function reorderCategories(
parentId: string | null,
categoryIds: string[]
): Promise<void> {
if (categoryIds.length === 0) return
await db.transaction().execute(async trx => {
let q = trx
.selectFrom('categories')
.select('id')
.where('id', 'in', categoryIds)
q = parentId === null
? q.where('parent_id', 'is', null)
: q.where('parent_id', '=', parentId)
const existing = await q.execute()
if (existing.length !== categoryIds.length) {
throw Object.assign(
new Error('Some category IDs are not siblings at this level'),
{ statusCode: 400 }
)
}
for (let i = 0; i < categoryIds.length; i++) {
const categoryId = categoryIds[i]
if (!categoryId) continue
await trx
.updateTable('categories')
.set({ menu_order: i, updated: sql`now()` })
.where('id', '=', categoryId)
.execute()
}
})
}
// Convenience helper for mutation endpoints that may touch many slugs
// at once (category rename cascades, bulk reorders, cross-category
// moves). Dedupes and purges each slug across all enabled locales.
export async function purgeSlugs(slugs: string[]): Promise<void> {
const unique = Array.from(new Set(slugs.filter(Boolean)))
await Promise.all(unique.map(s => purgeCmsPage(s)))
}