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