165 lines
4.7 KiB
TypeScript
165 lines
4.7 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,
|
|
};
|
|
}
|