508 lines
16 KiB
TypeScript
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)))
|
|
}
|