feat(hk): expand search and add operational user overview
CI / check (push) Successful in 54s
CI / deploy (push) Successful in 1m16s
CI / publish-container (push) Successful in 1m57s

This commit is contained in:
Simo committed 2026-09-09 21:14:10 +02:00
1 parent 27447ce029
commit 89526af344
44 files changed
+3740 -416

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
select: vi.fn(),
guard: vi.fn(),
permissions: new Set<string>(),
rateLimit: vi.fn(),
}));
vi.mock("@/lib/db", async () => ({
...(await import("@/db/schema")),
db: { select: mocks.select },
}));
vi.mock("@/lib/permissions", async () => ({
...(await import("@/lib/permission-slugs")),
canAccess: (permissions: { has: (slug: string) => boolean }, slug: string) =>
permissions.has(slug),
}));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: mocks.rateLimit }));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (
options: unknown,
handler: (request: NextRequest, context: unknown) => Promise<Response>,
) => {
mocks.guard(options);
return (request: NextRequest) =>
handler(request, {
session: { user: { id: 1, rank: 5 } },
permissions: {
isSuperAdmin: false,
has: (slug: string) => mocks.permissions.has(slug),
},
});
},
}));
import {
CatalogPages,
CatalogPagesBc,
Guilds,
ItemsBase,
User,
WebsiteArticles,
WebsiteHelpCenterTickets,
WebsiteTicket,
} from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { GET } from "./route";
const calls: {
table: unknown;
fields: Record<string, unknown>;
condition: unknown;
limit: number;
}[] = [];
const dialect = new MySqlDialect();
const rows = new Map<unknown, unknown[]>();
beforeEach(() => {
mocks.permissions.clear();
mocks.rateLimit.mockResolvedValue({ ok: true });
mocks.select.mockReset();
rows.clear();
calls.length = 0;
mocks.select.mockImplementation((fields) => {
const call = {
table: undefined as unknown,
fields,
condition: undefined as unknown,
limit: 0,
};
const builder = {
from(table: unknown) {
call.table = table;
return builder;
},
where(condition: unknown) {
call.condition = condition;
return builder;
},
orderBy() {
return builder;
},
limit(limit: number) {
call.limit = limit;
calls.push(call);
return Promise.resolve(rows.get(call.table) ?? []);
},
};
return builder;
});
});
const search = (q: string) =>
GET(
new NextRequest(
`https://cms.test/api/admin/search?q=${encodeURIComponent(q)}`,
),
);
describe("unified admin search", () => {
it("requires the staff dashboard gate and performs no entity reads without category permission", async () => {
expect(mocks.guard).toHaveBeenCalledWith({
permission: PERMS.ADMIN_DASHBOARD,
});
expect(await (await search("chair")).json()).toEqual({
ok: true,
results: [],
partial: false,
});
expect(mocks.select).not.toHaveBeenCalled();
});
it.each([
[PERMS.USERS_VIEW, [User, Guilds]],
[PERMS.NEWS_VIEW, [WebsiteArticles]],
[PERMS.CATALOG_VIEW, [ItemsBase, CatalogPages, CatalogPagesBc]],
[PERMS.TICKETS_VIEW, [WebsiteTicket, WebsiteHelpCenterTickets]],
])("reads only categories granted by %s", async (permission, tables) => {
mocks.permissions.add(permission);
await search("chair");
expect(calls.map((call) => call.table)).toEqual(tables);
expect(calls.every((call) => call.limit === 5)).toBe(true);
});
it("returns exact detail destinations without private user or ticket fields", async () => {
[PERMS.USERS_VIEW, PERMS.CATALOG_VIEW, PERMS.TICKETS_VIEW].forEach((p) => {
mocks.permissions.add(p);
});
rows.set(User, [{ id: 12, title: "Alice" }]);
rows.set(ItemsBase, [{ id: 12, title: "Chair", subtitle: "chair" }]);
rows.set(CatalogPagesBc, [{ id: 12, title: "Chairs" }]);
rows.set(WebsiteHelpCenterTickets, [{ id: 12n, title: "Help" }]);
const payload = await (await search("12")).json();
expect(payload.results.map((r: { url: string }) => r.url)).toEqual([
"/admin/users/show/12",
"/admin/items/12",
"/admin/catalog/builder-club/12",
"/admin/help-tickets/12",
]);
expect(calls.find((call) => call.table === User)?.fields).toEqual({
id: User.id,
title: User.username,
});
expect(
calls.find((call) => call.table === WebsiteHelpCenterTickets)?.fields,
).toEqual({
id: WebsiteHelpCenterTickets.id,
title: WebsiteHelpCenterTickets.title,
});
});
it("ignores short queries and treats wildcard input literally", async () => {
mocks.permissions.add(PERMS.USERS_VIEW);
await search(" a ");
expect(calls).toHaveLength(0);
await search("_%");
const query = dialect.sqlToQuery(
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
);
expect(query.params).toEqual(["%\\_\\%%"]);
expect(query.sql).not.toContain("mail");
});
it("bounds oversized queries and rejects partial numeric identifiers", async () => {
mocks.permissions.add(PERMS.USERS_VIEW);
await search("a".repeat(200));
expect(
dialect.sqlToQuery(
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
).params,
).toEqual([`%${"a".repeat(64)}%`]);
await search("12abc");
expect(
dialect.sqlToQuery(
calls[2].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
).params,
).toEqual(["%12abc%"]);
});
it("supports one-digit identifiers", async () => {
mocks.permissions.add(PERMS.USERS_VIEW);
await search("1");
expect(
dialect.sqlToQuery(
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
).params,
).toEqual(["%1%", 1]);
});
it("reports a partial failure while retaining authorized results", async () => {
mocks.permissions.add(PERMS.CATALOG_VIEW);
rows.set(ItemsBase, [{ id: 12, title: "Chair", subtitle: "chair" }]);
const implementation = mocks.select.getMockImplementation();
mocks.select.mockImplementation((fields) => {
const builder = implementation?.(fields);
const originalFrom = builder.from;
builder.from = (table: unknown) => {
originalFrom(table);
if (table === CatalogPagesBc)
builder.limit = () => Promise.reject(new Error("missing table"));
return builder;
};
return builder;
});
const payload = await (await search("chair")).json();
expect(payload.partial).toBe(true);
expect(payload.results).toHaveLength(1);
expect(payload.results[0].url).toBe("/admin/items/12");
});
it("caps the combined results while retaining each requested category", async () => {
[
PERMS.USERS_VIEW,
PERMS.NEWS_VIEW,
PERMS.CATALOG_VIEW,
PERMS.TICKETS_VIEW,
].forEach((permission) => {
mocks.permissions.add(permission);
});
[
User,
WebsiteArticles,
ItemsBase,
CatalogPages,
CatalogPagesBc,
WebsiteTicket,
WebsiteHelpCenterTickets,
].forEach((table) => {
rows.set(
table,
Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
title: "Match",
subtitle: "Name",
})),
);
});
const payload = await (await search("Match")).json();
expect(payload.results).toHaveLength(20);
expect(
new Set(payload.results.map((row: { type: string }) => row.type)),
).toEqual(
new Set(["users", "articles", "furniture", "catalog", "tickets"]),
);
});
it("rate limits without reading records", async () => {
mocks.permissions.add(PERMS.USERS_VIEW);
mocks.rateLimit.mockResolvedValue({ ok: false, retryAfter: 20 });
expect((await search("Alice")).status).toBe(429);
expect(mocks.select).not.toHaveBeenCalled();
});
});
+314 -125
View File
@@ -1,20 +1,24 @@
import { eq, like, or } from "drizzle-orm";
import { asc, eq, like, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { ADMIN_NAV_GROUPS, navItemIsAllowed } from "@/lib/admin-nav";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import {
CatalogPages,
CatalogPagesBc,
db,
Guilds,
ItemsBase,
Rooms,
User,
WebsiteArticles,
WebsiteHelpCenterTickets,
WebsiteRareValues,
WebsiteShopArticles,
WebsiteTicket,
} from "@/lib/db";
import { canAccess, PERMS } from "@/lib/permissions";
import { rateLimit } from "@/lib/rate-limit";
export type AdminSearchResult = {
type: string;
id: number | string;
@@ -22,16 +26,13 @@ export type AdminSearchResult = {
subtitle: string;
url: string;
};
const PER_TYPE = 5;
const MAX_TOTAL = 20;
const MAX_QUERY_LENGTH = 64;
/** Escape LIKE wildcards so user input can't widen the match. */
export function escapeLike(input: string): string {
function escapeLike(input: string): string {
return input.replace(/[\\%_]/g, (m) => `\\${m}`);
}
export const GET = withAdmin(
{ permission: PERMS.ADMIN_DASHBOARD },
async (request, context) => {
@@ -46,14 +47,12 @@ export const GET = withAdmin(
{ status: 429, headers: { "retry-after": String(limited.retryAfter) } },
);
}
const q = (request.nextUrl.searchParams.get("q") || "")
.trim()
.slice(0, MAX_QUERY_LENGTH);
if (q.length < 2) {
if (q.length < 2 && !/^[1-9]$/.test(q)) {
return apiOk({ results: [] });
}
const canSearch = (href: string) => {
const item = ADMIN_NAV_GROUPS.flatMap((group) => group.items).find(
(item) => item.href === href,
@@ -67,123 +66,272 @@ export const GET = withAdmin(
})
);
};
const pattern = `%${escapeLike(q)}%`;
const idExact = Number.parseInt(q, 10);
const hasId = Number.isFinite(idExact) && String(idExact) === q;
const [users, articles, rooms, guilds, shopArticles, rareValues] =
await Promise.all([
canSearch("/admin/users")
? db
.select({
id: User.id,
title: User.username,
subtitle: User.mail,
})
.from(User)
.where(
or(
like(User.username, pattern),
like(User.mail, pattern),
...(hasId ? [eq(User.id, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
canSearch("/admin/articles")
? db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
subtitle: WebsiteArticles.slug,
})
.from(WebsiteArticles)
.where(
or(
like(WebsiteArticles.title, pattern),
like(WebsiteArticles.slug, pattern),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
canSearch("/admin/rooms")
? db
.select({
id: Rooms.id,
title: Rooms.name,
subtitle: Rooms.ownerName,
})
.from(Rooms)
.where(
or(
like(Rooms.name, pattern),
...(hasId ? [eq(Rooms.id, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
canSearch("/admin/guilds")
? db
.select({
id: Guilds.id,
title: Guilds.name,
subtitle: Guilds.description,
})
.from(Guilds)
.where(
or(
like(Guilds.name, pattern),
...(hasId ? [eq(Guilds.id, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
canSearch("/admin/shop")
? db
.select({
id: WebsiteShopArticles.id,
title: WebsiteShopArticles.name,
subtitle: WebsiteShopArticles.info,
})
.from(WebsiteShopArticles)
.where(
or(
like(WebsiteShopArticles.name, pattern),
...(hasId
? [eq(WebsiteShopArticles.id, BigInt(idExact))]
: []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
canSearch("/admin/rare-values")
? db
.select({
id: WebsiteRareValues.id,
title: WebsiteRareValues.name,
subtitle: WebsiteRareValues.itemId,
})
.from(WebsiteRareValues)
.where(
or(
like(WebsiteRareValues.name, pattern),
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
]);
const hasId =
Number.isSafeInteger(idExact) && idExact > 0 && String(idExact) === q;
let partial = false;
const [
users,
articles,
rooms,
guilds,
shopArticles,
rareValues,
furniture,
catalog,
catalogBc,
tickets,
helpTickets,
] = await Promise.all([
canSearch("/admin/users")
? db
.select({
id: User.id,
title: User.username,
})
.from(User)
.where(
or(
like(User.username, pattern),
...(hasId ? [eq(User.id, idExact)] : []),
),
)
.orderBy(asc(User.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canSearch("/admin/articles")
? db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
subtitle: WebsiteArticles.slug,
})
.from(WebsiteArticles)
.where(
or(
like(WebsiteArticles.title, pattern),
like(WebsiteArticles.slug, pattern),
),
)
.orderBy(asc(WebsiteArticles.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canSearch("/admin/rooms")
? db
.select({
id: Rooms.id,
title: Rooms.name,
subtitle: Rooms.ownerName,
})
.from(Rooms)
.where(
or(
like(Rooms.name, pattern),
...(hasId ? [eq(Rooms.id, idExact)] : []),
),
)
.orderBy(asc(Rooms.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canSearch("/admin/guilds")
? db
.select({
id: Guilds.id,
title: Guilds.name,
subtitle: Guilds.description,
})
.from(Guilds)
.where(
or(
like(Guilds.name, pattern),
...(hasId ? [eq(Guilds.id, idExact)] : []),
),
)
.orderBy(asc(Guilds.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canSearch("/admin/shop")
? db
.select({
id: WebsiteShopArticles.id,
title: WebsiteShopArticles.name,
subtitle: WebsiteShopArticles.info,
})
.from(WebsiteShopArticles)
.where(
or(
like(WebsiteShopArticles.name, pattern),
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []),
),
)
.orderBy(asc(WebsiteShopArticles.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canSearch("/admin/rare-values")
? db
.select({
id: WebsiteRareValues.id,
title: WebsiteRareValues.name,
subtitle: WebsiteRareValues.itemId,
})
.from(WebsiteRareValues)
.where(
or(
like(WebsiteRareValues.name, pattern),
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
),
)
.orderBy(asc(WebsiteRareValues.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canAccess(
context.permissions,
PERMS.CATALOG_VIEW,
context.session.user.rank,
)
? db
.select({
id: ItemsBase.id,
title: ItemsBase.publicName,
subtitle: ItemsBase.itemName,
})
.from(ItemsBase)
.where(
or(
like(ItemsBase.publicName, pattern),
like(ItemsBase.itemName, pattern),
...(hasId ? [eq(ItemsBase.id, idExact)] : []),
),
)
.orderBy(asc(ItemsBase.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canAccess(
context.permissions,
PERMS.CATALOG_VIEW,
context.session.user.rank,
)
? db
.select({ id: CatalogPages.id, title: CatalogPages.caption })
.from(CatalogPages)
.where(
or(
like(CatalogPages.caption, pattern),
...(hasId ? [eq(CatalogPages.id, idExact)] : []),
),
)
.orderBy(asc(CatalogPages.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canAccess(
context.permissions,
PERMS.CATALOG_VIEW,
context.session.user.rank,
)
? db
.select({ id: CatalogPagesBc.id, title: CatalogPagesBc.caption })
.from(CatalogPagesBc)
.where(
or(
like(CatalogPagesBc.caption, pattern),
...(hasId ? [eq(CatalogPagesBc.id, idExact)] : []),
),
)
.orderBy(asc(CatalogPagesBc.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canAccess(
context.permissions,
PERMS.TICKETS_VIEW,
context.session.user.rank,
)
? db
.select({ id: WebsiteTicket.id, title: WebsiteTicket.subject })
.from(WebsiteTicket)
.where(
or(
like(WebsiteTicket.subject, pattern),
...(hasId ? [eq(WebsiteTicket.id, idExact)] : []),
),
)
.orderBy(asc(WebsiteTicket.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canAccess(
context.permissions,
PERMS.TICKETS_VIEW,
context.session.user.rank,
)
? db
.select({
id: WebsiteHelpCenterTickets.id,
title: WebsiteHelpCenterTickets.title,
})
.from(WebsiteHelpCenterTickets)
.where(
or(
like(WebsiteHelpCenterTickets.title, pattern),
...(hasId
? [eq(WebsiteHelpCenterTickets.id, BigInt(idExact))]
: []),
),
)
.orderBy(asc(WebsiteHelpCenterTickets.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
]);
const groupMap: Record<
string,
{ type: string; items: Array<AdminSearchResult> }
> = {
furniture: { type: "furniture", items: [] },
catalog: { type: "catalog", items: [] },
tickets: { type: "tickets", items: [] },
users: { type: "users", items: [] },
articles: { type: "articles", items: [] },
rooms: { type: "rooms", items: [] },
@@ -191,13 +339,12 @@ export const GET = withAdmin(
shop: { type: "shop", items: [] },
rareValues: { type: "rareValues", items: [] },
};
for (const r of users) {
groupMap.users.items.push({
type: "users",
id: r.id,
title: r.title,
subtitle: r.subtitle || "",
subtitle: `#${r.id}`,
url: `/admin/users/show/${r.id}`,
});
}
@@ -246,10 +393,53 @@ export const GET = withAdmin(
url: `/admin/rare-values/${r.id}`,
});
}
for (const r of furniture) {
groupMap.furniture.items.push({
type: "furniture",
id: r.id,
title: r.title || r.subtitle || `#${r.id}`,
subtitle: `${r.subtitle || ""} · #${r.id}`,
url: `/admin/items/${r.id}`,
});
}
for (const [rows, kind] of [
[catalog, "normal"],
[catalogBc, "bc"],
] as const) {
for (const r of rows)
groupMap.catalog.items.push({
type: "catalog",
id: `${kind}-${r.id}`,
title: r.title || `#${r.id}`,
subtitle: kind === "bc" ? `BC · #${r.id}` : `#${r.id}`,
url:
kind === "bc"
? `/admin/catalog/builder-club/${r.id}`
: `/admin/catalog/${r.id}`,
});
}
for (const r of tickets)
groupMap.tickets.items.push({
type: "tickets",
id: `cms-${r.id}`,
title: r.title,
subtitle: `#${r.id}`,
url: `/admin/tickets/${r.id}`,
});
for (const r of helpTickets)
groupMap.tickets.items.push({
type: "tickets",
id: `help-${r.id}`,
title: r.title,
subtitle: `#${r.id}`,
url: `/admin/help-tickets/${r.id}`,
});
const results: AdminSearchResult[] = [];
const order = [
"users",
"furniture",
"catalog",
"tickets",
"articles",
"rooms",
"guilds",
@@ -268,7 +458,6 @@ export const GET = withAdmin(
}
if (!added) break;
}
return apiOk({ results });
return apiOk({ results, partial });
},
);