feat(hk): expand search and add operational user overview
This commit is contained in:
1 parent
27447ce029
commit
89526af344
44 files changed
+3740
-416
No files matched your search
@@ -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
@@ -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 });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user