151 lines
4.8 KiB
TypeScript
151 lines
4.8 KiB
TypeScript
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,
|
|
}
|
|
}
|