fix: restore complete admin feature dependencies

This commit is contained in:
Simo committed 2026-07-11 21:15:54 +02:00
1 parent 5b4228261a
commit 0cd753c735
115 files changed
+18565 -4930

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
import { redirect } from 'next/navigation'
import { calcPagination, parseListParams } from './admin-helpers'
import { canAccess, getAdminContext, type PermissionSet } from './permissions'
import { prisma } from './prisma'
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
type PrismaModel = any
interface AdminListConfig<TRow> {
/** Permission slug required to view this page */
permission: string
/** Prisma model name (e.g. 'user', 'ban', 'room') */
model: string
/** Search field paths for OR filter. Use dot notation for nested relations (e.g. 'owner.username') */
searchFields: string[]
/** Default sort when no sort param provided. Default: { id: 'desc' } */
defaultSort?: Record<string, 'asc' | 'desc'>
/** Default items per page. Default: 20 */
defaultPerPage?: number
/** Prisma include clause for relations */
include?: Record<string, unknown>
/** Prisma select clause (mutually exclusive with include) */
select?: Record<string, unknown>
/** Fixed WHERE conditions merged with search (e.g. { online: '1' }) */
baseWhere?: Record<string, unknown>
/**
* Map URL ?filter_* params to Prisma WHERE conditions. Each entry receives
* the raw string value from the URL and returns the WHERE fragment to merge.
* Returning null/undefined drops the filter.
*
* @example
* filterMap: {
* filter_rank: (v) => ({ rank: { gte: Number(v) } }),
* filter_online: (v) => ({ online: v }),
* }
*/
filterMap?: Record<string, (value: string) => Record<string, unknown> | null | undefined>
/** Transform raw Prisma row to the shape needed by the table component */
mapRow?: (row: PrismaModel) => TRow
}
interface AdminListResult<TRow> {
rows: TRow[]
total: number
page: number
perPage: number
lastPage: number
locale: string
permissions: PermissionSet
rank: number
}
/**
* Build a nested Prisma WHERE condition from a dot-notation field path.
* e.g. 'owner.username' => { owner: { username: { contains: search } } }
* e.g. 'username' => { username: { contains: search } }
*/
function buildFieldCondition(fieldPath: string, search: string): Record<string, unknown> {
const parts = fieldPath.split('.')
if (parts.length === 1) {
return { [parts[0]]: { contains: search } }
}
// Build nested object from right to left
let result: Record<string, unknown> = { [parts[parts.length - 1]]: { contains: search } }
for (let i = parts.length - 2; i >= 0; i--) {
result = { [parts[i]]: result }
}
return result
}
/**
* Generic admin list data fetcher.
* Centralizes the common pattern: auth check, param parsing, WHERE/orderBy construction,
* Prisma query with pagination, and row mapping.
*/
export async function fetchAdminList<TRow = PrismaModel>(
config: AdminListConfig<TRow>,
paramsPromise: Promise<{ locale: string }>,
searchParamsPromise: Promise<Record<string, string>>,
): Promise<AdminListResult<TRow>> {
const { locale } = await paramsPromise
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, config.permission, session.user.rank)) {
redirect('/admin')
}
const rawParams = await searchParamsPromise
const sp = new URLSearchParams(rawParams)
const parsed = parseListParams(sp)
const perPage = config.defaultPerPage
? Number(rawParams.perPage) || config.defaultPerPage
: parsed.perPage
const { search, page, sort, order } = parsed
// Build WHERE clause
const searchWhere =
search && config.searchFields.length > 0
? { OR: config.searchFields.map((field) => buildFieldCondition(field, search)) }
: {}
// Apply URL filter_* params via filterMap
const filterWhere: Record<string, unknown> = {}
if (config.filterMap) {
for (const [paramKey, build] of Object.entries(config.filterMap)) {
const value = rawParams[paramKey]
if (!value) continue
const fragment = build(value)
if (fragment) Object.assign(filterWhere, fragment)
}
}
const where = {
...(config.baseWhere ?? {}),
...filterWhere,
...searchWhere,
}
// Build orderBy
const orderBy = sort ? { [sort]: order } : (config.defaultSort ?? { id: 'desc' as const })
// Access Prisma model dynamically
const delegate = (prisma as PrismaModel)[config.model]
const queryArgs: PrismaModel = {
where,
orderBy,
skip: (page - 1) * perPage,
take: perPage,
}
if (config.include) queryArgs.include = config.include
if (config.select) queryArgs.select = config.select
const [rawRows, total] = await Promise.all([
delegate.findMany(queryArgs),
delegate.count({ where }),
])
const pagination = calcPagination(total, page, perPage)
const rows = config.mapRow ? rawRows.map(config.mapRow) : rawRows
return {
rows,
...pagination,
locale,
permissions,
rank: session.user.rank,
}
}