Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"
This reverts commit488b6e57c4, reversing changes made tob506b4499a.
This commit is contained in:
1 parent
488b6e57c4
commit
b1ddda66ff
802 files changed
+61370
-76659
No files matched your search
@@ -33,11 +33,12 @@ describe("admin content module port", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retains public server actions after the ASE route cutover", () => {
|
||||
for (const module of ["events", "polls"]) {
|
||||
it("provides locale-free routes and matching server actions", () => {
|
||||
for (const module of ["events", "polls", "banners", "prefixes"]) {
|
||||
expect(existsSync(resolve(`src/app/admin/${module}/page.tsx`))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(existsSync(resolve(`src/actions/${module}.ts`))).toBe(true);
|
||||
}
|
||||
expect(existsSync(resolve("src/app/admin"))).toBe(false);
|
||||
expect(existsSync(resolve("src/app/ase/layout.tsx"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { calcPagination, parseListParams } from "./admin-helpers";
|
||||
import { canAccess, getAdminContext, type PermissionSet } from "./permissions";
|
||||
|
||||
interface AdminListConfig<TRow> {
|
||||
/** Permission slug required to view this page */
|
||||
permission: string;
|
||||
/** Default items per page. Default: 20 */
|
||||
defaultPerPage?: number;
|
||||
/**
|
||||
* Runs the actual data query (Drizzle). Receives parsed list params and
|
||||
* returns the rows + total count for the current page.
|
||||
*/
|
||||
fetch: (args: {
|
||||
search: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
rawParams: Record<string, string>;
|
||||
}) => Promise<{ rows: TRow[]; total: number }>;
|
||||
}
|
||||
|
||||
interface AdminListResult<TRow> {
|
||||
rows: TRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
locale: string;
|
||||
permissions: PermissionSet;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic admin list data fetcher.
|
||||
* Centralizes the common pattern: auth check, param parsing, pagination and
|
||||
* row mapping. The caller supplies a Drizzle query via `config.fetch`.
|
||||
*/
|
||||
export async function fetchAdminList<TRow>(
|
||||
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 { rows, total } = await config.fetch({
|
||||
search: parsed.search,
|
||||
page: parsed.page,
|
||||
perPage,
|
||||
rawParams,
|
||||
});
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, perPage);
|
||||
|
||||
return {
|
||||
rows,
|
||||
...pagination,
|
||||
locale,
|
||||
permissions,
|
||||
rank: session.user.rank,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { LayoutDashboard, Newspaper, Settings } from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AdminNavGroup } from "@/lib/admin-nav";
|
||||
import {
|
||||
ADMIN_NAV_PINNED_HREFS,
|
||||
applyAdminNavConfig,
|
||||
parseAdminNavConfig,
|
||||
serializeAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
|
||||
const catalog: AdminNavGroup[] = [
|
||||
{
|
||||
labelKey: "overview",
|
||||
icon: LayoutDashboard,
|
||||
items: [
|
||||
{ href: "/admin", labelKey: "dashboard", icon: LayoutDashboard },
|
||||
{ href: "/admin/menu", labelKey: "menu", icon: Settings },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "content",
|
||||
icon: Newspaper,
|
||||
items: [
|
||||
{ href: "/admin/articles", labelKey: "articles", icon: Newspaper },
|
||||
{ href: "/admin/photos", labelKey: "photos", icon: Newspaper },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "system",
|
||||
icon: Settings,
|
||||
items: [{ href: "/admin/settings", labelKey: "settings", icon: Settings }],
|
||||
},
|
||||
];
|
||||
|
||||
describe("admin-nav-config", () => {
|
||||
it("parses empty / invalid as empty config", () => {
|
||||
expect(parseAdminNavConfig(null)).toEqual({});
|
||||
expect(parseAdminNavConfig("")).toEqual({});
|
||||
expect(parseAdminNavConfig("not-json")).toEqual({});
|
||||
expect(parseAdminNavConfig("[]")).toEqual({});
|
||||
});
|
||||
|
||||
it("reorders groups and items", () => {
|
||||
const applied = applyAdminNavConfig(catalog, {
|
||||
groupOrder: ["system", "content", "overview"],
|
||||
itemOrder: {
|
||||
content: ["/admin/photos", "/admin/articles"],
|
||||
},
|
||||
});
|
||||
expect(applied.map((g) => g.labelKey)).toEqual([
|
||||
"system",
|
||||
"content",
|
||||
"overview",
|
||||
]);
|
||||
expect(applied[1]?.items.map((i) => i.href)).toEqual([
|
||||
"/admin/photos",
|
||||
"/admin/articles",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides groups and items but keeps pinned hrefs", () => {
|
||||
const applied = applyAdminNavConfig(catalog, {
|
||||
hiddenGroups: ["system"],
|
||||
hiddenItems: ["/admin/photos", "/admin", "/admin/menu"],
|
||||
});
|
||||
expect(applied.map((g) => g.labelKey)).toEqual(["overview", "content"]);
|
||||
expect(applied[0]?.items.map((i) => i.href)).toEqual([
|
||||
"/admin",
|
||||
"/admin/menu",
|
||||
]);
|
||||
expect(applied[1]?.items.map((i) => i.href)).toEqual(["/admin/articles"]);
|
||||
expect(ADMIN_NAV_PINNED_HREFS.has("/admin")).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips serialize → parse", () => {
|
||||
const raw = serializeAdminNavConfig({
|
||||
groupOrder: ["content"],
|
||||
hiddenGroups: ["system"],
|
||||
hiddenItems: ["/admin/photos", "/admin"],
|
||||
itemOrder: { content: ["/admin/articles"] },
|
||||
});
|
||||
const parsed = parseAdminNavConfig(raw);
|
||||
expect(parsed.groupOrder).toEqual(["content"]);
|
||||
expect(parsed.hiddenGroups).toEqual(["system"]);
|
||||
expect(parsed.hiddenItems).toEqual(["/admin/photos"]);
|
||||
expect(parsed.itemOrder).toEqual({ content: ["/admin/articles"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { AdminNavGroup } from "@/lib/admin-nav";
|
||||
|
||||
/** website_settings key storing JSON overlay for the admin sidebar. */
|
||||
export const ADMIN_NAV_CONFIG_KEY = "admin_nav_config";
|
||||
|
||||
/** Hrefs that cannot be hidden (dashboard + menu editor). */
|
||||
export const ADMIN_NAV_PINNED_HREFS = new Set(["/admin", "/admin/menu"]);
|
||||
|
||||
export type AdminNavConfig = {
|
||||
/** Group labelKeys in display order. Missing groups append in catalog order. */
|
||||
groupOrder?: string[];
|
||||
/** Group labelKeys fully hidden from the sidebar. */
|
||||
hiddenGroups?: string[];
|
||||
/** Item hrefs hidden from the sidebar (except pinned). */
|
||||
hiddenItems?: string[];
|
||||
/** Per-group item href order. Missing items append in catalog order. */
|
||||
itemOrder?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
function isStringArray(v: unknown): v is string[] {
|
||||
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
||||
}
|
||||
|
||||
/** Parse stored JSON; invalid / empty → empty config. */
|
||||
export function parseAdminNavConfig(
|
||||
raw: string | null | undefined,
|
||||
): AdminNavConfig {
|
||||
if (!raw?.trim()) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
const o = parsed as Record<string, unknown>;
|
||||
const config: AdminNavConfig = {};
|
||||
if (isStringArray(o.groupOrder)) config.groupOrder = o.groupOrder;
|
||||
if (isStringArray(o.hiddenGroups)) config.hiddenGroups = o.hiddenGroups;
|
||||
if (isStringArray(o.hiddenItems)) config.hiddenItems = o.hiddenItems;
|
||||
if (
|
||||
o.itemOrder &&
|
||||
typeof o.itemOrder === "object" &&
|
||||
!Array.isArray(o.itemOrder)
|
||||
) {
|
||||
const itemOrder: Record<string, string[]> = {};
|
||||
for (const [k, v] of Object.entries(
|
||||
o.itemOrder as Record<string, unknown>,
|
||||
)) {
|
||||
if (isStringArray(v)) itemOrder[k] = v;
|
||||
}
|
||||
config.itemOrder = itemOrder;
|
||||
}
|
||||
return config;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function orderByKeys<T>(
|
||||
items: T[],
|
||||
order: string[] | undefined,
|
||||
keyOf: (item: T) => string,
|
||||
): T[] {
|
||||
if (!order?.length) return items;
|
||||
const byKey = new Map(items.map((item) => [keyOf(item), item]));
|
||||
const seen = new Set<string>();
|
||||
const out: T[] = [];
|
||||
for (const key of order) {
|
||||
const hit = byKey.get(key);
|
||||
if (!hit || seen.has(key)) continue;
|
||||
out.push(hit);
|
||||
seen.add(key);
|
||||
}
|
||||
for (const item of items) {
|
||||
const key = keyOf(item);
|
||||
if (seen.has(key)) continue;
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply owner overlay (hide + reorder) on top of the code catalog.
|
||||
* Does not apply ACL — callers filter permissions afterwards.
|
||||
*/
|
||||
export function applyAdminNavConfig(
|
||||
groups: AdminNavGroup[],
|
||||
config: AdminNavConfig,
|
||||
): AdminNavGroup[] {
|
||||
const hiddenGroups = new Set(config.hiddenGroups ?? []);
|
||||
const hiddenItems = new Set(config.hiddenItems ?? []);
|
||||
|
||||
const mapped = groups
|
||||
.filter((g) => !hiddenGroups.has(g.labelKey))
|
||||
.map((group) => {
|
||||
const items = group.items.filter((item) => {
|
||||
if (ADMIN_NAV_PINNED_HREFS.has(item.href)) return true;
|
||||
return !hiddenItems.has(item.href);
|
||||
});
|
||||
const orderedItems = orderByKeys(
|
||||
items,
|
||||
config.itemOrder?.[group.labelKey],
|
||||
(item) => item.href,
|
||||
);
|
||||
return { ...group, items: orderedItems };
|
||||
})
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
return orderByKeys(mapped, config.groupOrder, (g) => g.labelKey);
|
||||
}
|
||||
|
||||
export function serializeAdminNavConfig(config: AdminNavConfig): string {
|
||||
return JSON.stringify({
|
||||
groupOrder: config.groupOrder ?? [],
|
||||
hiddenGroups: config.hiddenGroups ?? [],
|
||||
hiddenItems: (config.hiddenItems ?? []).filter(
|
||||
(href) => !ADMIN_NAV_PINNED_HREFS.has(href),
|
||||
),
|
||||
itemOrder: config.itemOrder ?? {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Calendar } from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ADMIN_NAV_GROUPS,
|
||||
collectNavPermissionSlugs,
|
||||
findAdminHub,
|
||||
navItemIsAllowed,
|
||||
} from "./admin-nav";
|
||||
|
||||
describe("findAdminHub", () => {
|
||||
it("matches by prefix", () => {
|
||||
expect(findAdminHub("/admin/events")?.id).toBe("events");
|
||||
expect(findAdminHub("/admin/events/123")?.id).toBe("events");
|
||||
});
|
||||
it("uses longest-prefix match", () => {
|
||||
expect(findAdminHub("/admin/tickets/desk")?.id).toBe("tickets");
|
||||
});
|
||||
it("returns null for non-hub paths", () => {
|
||||
expect(findAdminHub("/admin/users")).toBeNull();
|
||||
expect(findAdminHub("/login")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("navItemIsAllowed", () => {
|
||||
const item = {
|
||||
href: "/admin/x",
|
||||
labelKey: "x",
|
||||
icon: Calendar,
|
||||
permission: "a.view",
|
||||
};
|
||||
|
||||
it("always allows for super admins", () => {
|
||||
expect(
|
||||
navItemIsAllowed(item, { isSuperAdmin: true, has: () => false }),
|
||||
).toBe(true);
|
||||
});
|
||||
it("allows when any needed slug is granted", () => {
|
||||
expect(
|
||||
navItemIsAllowed(item, {
|
||||
isSuperAdmin: false,
|
||||
has: (s) => s === "a.view",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
it("denies when no slug is granted", () => {
|
||||
expect(
|
||||
navItemIsAllowed(item, { isSuperAdmin: false, has: () => false }),
|
||||
).toBe(false);
|
||||
});
|
||||
it("allows items without a permission requirement", () => {
|
||||
expect(
|
||||
navItemIsAllowed(
|
||||
{ href: "/admin/d", labelKey: "d", icon: Calendar },
|
||||
{ isSuperAdmin: false, has: () => false },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it("supports an array of permissions (any match)", () => {
|
||||
const multi = {
|
||||
href: "/admin/m",
|
||||
labelKey: "m",
|
||||
icon: Calendar,
|
||||
permission: ["x.view", "y.view"],
|
||||
};
|
||||
expect(
|
||||
navItemIsAllowed(multi, {
|
||||
isSuperAdmin: false,
|
||||
has: (s) => s === "y.view",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectNavPermissionSlugs", () => {
|
||||
it("returns unique slugs referenced by the sidebar", () => {
|
||||
const slugs = collectNavPermissionSlugs();
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
expect(slugs.length).toBeGreaterThan(0);
|
||||
});
|
||||
it("is consistent with the nav groups", () => {
|
||||
const groupSlugs = new Set<string>();
|
||||
for (const group of ADMIN_NAV_GROUPS) {
|
||||
for (const item of group.items) {
|
||||
if (!item.permission) continue;
|
||||
const needed = Array.isArray(item.permission)
|
||||
? item.permission
|
||||
: [item.permission];
|
||||
for (const s of needed) groupSlugs.add(s);
|
||||
}
|
||||
}
|
||||
expect([...groupSlugs]).toEqual(collectNavPermissionSlugs());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,706 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Calendar,
|
||||
CalendarDays,
|
||||
ClipboardList,
|
||||
Cog,
|
||||
Compass,
|
||||
FileText,
|
||||
Filter,
|
||||
Gavel,
|
||||
HelpCircle,
|
||||
Image,
|
||||
KeyRound,
|
||||
Languages,
|
||||
LayoutDashboard,
|
||||
ListOrdered,
|
||||
type LucideIcon,
|
||||
Megaphone,
|
||||
Monitor,
|
||||
Newspaper,
|
||||
Package,
|
||||
Palette,
|
||||
Radio,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
ShoppingCart,
|
||||
Sparkles,
|
||||
Store,
|
||||
Tags,
|
||||
Terminal,
|
||||
Ticket,
|
||||
Trophy,
|
||||
Users,
|
||||
UsersRound,
|
||||
Volume2,
|
||||
Vote,
|
||||
Wifi,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
export interface AdminHubTab {
|
||||
href: string;
|
||||
/** Key under pages.admin.hubs.tabs.* */
|
||||
labelKey: string;
|
||||
match?: string[];
|
||||
/** Optional group id for multi-row tab strips (e.g. Radio primary/tools). */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface AdminHubDefinition {
|
||||
id: string;
|
||||
/** Key under pages.admin.hubs.* for title/subtitle */
|
||||
titleKey: string;
|
||||
subtitleKey: string;
|
||||
icon: LucideIcon;
|
||||
/** Path prefixes belonging to this hub (for chrome + sidebar active). */
|
||||
prefixes: string[];
|
||||
tabs: AdminHubTab[];
|
||||
}
|
||||
|
||||
export interface AdminNavItem {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
/**
|
||||
* ACL slug(s) required to show this item. Any match is enough.
|
||||
* Omit only for the dashboard (already gated by admin.dashboard).
|
||||
*/
|
||||
permission?: string | readonly string[];
|
||||
/** When set, sidebar item is active if pathname matches any prefix. */
|
||||
matchPrefixes?: string[];
|
||||
/** Prefixes that must NOT count as active (e.g. /admin/users vs multi-accounts). */
|
||||
matchExcludePrefixes?: string[];
|
||||
}
|
||||
|
||||
export interface AdminNavGroup {
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
items: AdminNavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hubs with real sibling tabs only. Standalone features have no hub chrome
|
||||
* (sidebar links go straight to the page).
|
||||
*/
|
||||
export const ADMIN_HUBS: AdminHubDefinition[] = [
|
||||
{
|
||||
id: "events",
|
||||
titleKey: "events",
|
||||
subtitleKey: "eventsSubtitle",
|
||||
icon: Calendar,
|
||||
prefixes: ["/admin/events"],
|
||||
tabs: [
|
||||
{ href: "/admin/events", labelKey: "events", match: ["/admin/events"] },
|
||||
{ href: "/admin/events/types", labelKey: "eventTypes" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tickets",
|
||||
titleKey: "tickets",
|
||||
subtitleKey: "ticketsSubtitle",
|
||||
icon: Ticket,
|
||||
prefixes: ["/admin/tickets", "/admin/help-tickets"],
|
||||
tabs: [
|
||||
{
|
||||
href: "/admin/tickets",
|
||||
labelKey: "ticketInbox",
|
||||
match: ["/admin/tickets"],
|
||||
},
|
||||
{
|
||||
href: "/admin/tickets/desk",
|
||||
labelKey: "tickets",
|
||||
match: ["/admin/tickets/desk"],
|
||||
},
|
||||
{
|
||||
href: "/admin/help-tickets",
|
||||
labelKey: "helpTickets",
|
||||
match: ["/admin/help-tickets"],
|
||||
},
|
||||
{ href: "/admin/tickets/templates", labelKey: "templates" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "staff-access",
|
||||
titleKey: "staffAccess",
|
||||
subtitleKey: "staffAccessSubtitle",
|
||||
icon: KeyRound,
|
||||
prefixes: ["/admin/teams", "/admin/permissions", "/admin/housekeeping"],
|
||||
tabs: [
|
||||
{ href: "/admin/teams", labelKey: "teams" },
|
||||
{
|
||||
href: "/admin/permissions",
|
||||
labelKey: "permissions",
|
||||
match: ["/admin/permissions"],
|
||||
},
|
||||
{ href: "/admin/housekeeping", labelKey: "housekeeping" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "moderation",
|
||||
titleKey: "moderation",
|
||||
subtitleKey: "moderationSubtitle",
|
||||
icon: Gavel,
|
||||
prefixes: ["/admin/moderation"],
|
||||
tabs: [
|
||||
{ href: "/admin/moderation", labelKey: "overview" },
|
||||
{ href: "/admin/moderation/actions", labelKey: "actions" },
|
||||
{
|
||||
href: "/admin/moderation/cfh",
|
||||
labelKey: "cfh",
|
||||
match: ["/admin/moderation/cfh"],
|
||||
},
|
||||
{ href: "/admin/moderation/team", labelKey: "team" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "radio",
|
||||
titleKey: "radio",
|
||||
subtitleKey: "radioSubtitle",
|
||||
icon: Radio,
|
||||
prefixes: ["/admin/radio"],
|
||||
tabs: [
|
||||
{ href: "/admin/radio", labelKey: "overview", group: "primary" },
|
||||
{ href: "/admin/radio/history", labelKey: "history", group: "primary" },
|
||||
{
|
||||
href: "/admin/radio/moderation",
|
||||
labelKey: "moderationTab",
|
||||
group: "primary",
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/monitoring",
|
||||
labelKey: "monitoring",
|
||||
group: "primary",
|
||||
},
|
||||
{
|
||||
href: "/admin/radio/settings",
|
||||
labelKey: "settingsTab",
|
||||
group: "primary",
|
||||
},
|
||||
{ href: "/admin/radio/banners", labelKey: "banners", group: "tools" },
|
||||
{ href: "/admin/radio/embed", labelKey: "embed", group: "tools" },
|
||||
{ href: "/admin/radio/api-keys", labelKey: "apiKeys", group: "tools" },
|
||||
{ href: "/admin/radio/points", labelKey: "points", group: "tools" },
|
||||
{ href: "/admin/radio/ranks", labelKey: "ranks", group: "tools" },
|
||||
{ href: "/admin/radio/autodj", labelKey: "autoDj", group: "tools" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
titleKey: "analytics",
|
||||
subtitleKey: "analyticsSubtitle",
|
||||
icon: Activity,
|
||||
prefixes: ["/admin/analytics"],
|
||||
tabs: [
|
||||
{
|
||||
href: "/admin/analytics",
|
||||
labelKey: "analytics",
|
||||
match: ["/admin/analytics"],
|
||||
},
|
||||
{ href: "/admin/analytics/activity", labelKey: "activity" },
|
||||
{ href: "/admin/analytics/economy", labelKey: "economy" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "devops",
|
||||
titleKey: "devops",
|
||||
subtitleKey: "devopsSubtitle",
|
||||
icon: Server,
|
||||
prefixes: ["/admin/devops"],
|
||||
tabs: [
|
||||
{ href: "/admin/devops", labelKey: "devops", match: ["/admin/devops"] },
|
||||
{ href: "/admin/devops/errors", labelKey: "errors" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Longest-prefix match so nested routes (e.g. /admin/logs/audit) win. */
|
||||
export function findAdminHub(pathname: string): AdminHubDefinition | null {
|
||||
let best: AdminHubDefinition | null = null;
|
||||
let bestLen = -1;
|
||||
for (const hub of ADMIN_HUBS) {
|
||||
for (const prefix of hub.prefixes) {
|
||||
const hit = pathname === prefix || pathname.startsWith(`${prefix}/`);
|
||||
if (hit && prefix.length > bestLen) {
|
||||
best = hub;
|
||||
bestLen = prefix.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Feature-first sidebar: one entry per feature; hubs only for sibling tabs. */
|
||||
export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
{
|
||||
labelKey: "overview",
|
||||
icon: LayoutDashboard,
|
||||
items: [
|
||||
{
|
||||
href: "/admin",
|
||||
labelKey: "dashboard",
|
||||
icon: LayoutDashboard,
|
||||
permission: PERMS.ADMIN_DASHBOARD,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "content",
|
||||
icon: Newspaper,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/articles",
|
||||
labelKey: "articles",
|
||||
icon: Newspaper,
|
||||
permission: PERMS.NEWS_VIEW,
|
||||
matchPrefixes: ["/admin/articles"],
|
||||
},
|
||||
{
|
||||
href: "/admin/photos",
|
||||
labelKey: "photos",
|
||||
icon: Image,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/banners",
|
||||
labelKey: "banners",
|
||||
icon: Megaphone,
|
||||
permission: PERMS.BANNERS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/ads",
|
||||
labelKey: "advertisements",
|
||||
icon: FileText,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
matchPrefixes: ["/admin/ads"],
|
||||
},
|
||||
{
|
||||
href: "/admin/media",
|
||||
labelKey: "media",
|
||||
icon: Image,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/navigation",
|
||||
labelKey: "navigator",
|
||||
icon: Compass,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/help-questions",
|
||||
labelKey: "helpCenter",
|
||||
icon: HelpCircle,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
matchPrefixes: ["/admin/help-questions"],
|
||||
},
|
||||
{
|
||||
href: "/admin/writeable-boxes",
|
||||
labelKey: "writeableBoxes",
|
||||
icon: Package,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/tags",
|
||||
labelKey: "tags",
|
||||
icon: Tags,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/prefixes",
|
||||
labelKey: "prefixes",
|
||||
icon: Sparkles,
|
||||
permission: PERMS.PREFIXES_VIEW,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "community",
|
||||
icon: Calendar,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/events",
|
||||
labelKey: "events",
|
||||
icon: Calendar,
|
||||
permission: PERMS.EVENTS_VIEW,
|
||||
matchPrefixes: ["/admin/events"],
|
||||
},
|
||||
{
|
||||
href: "/admin/polls",
|
||||
labelKey: "polls",
|
||||
icon: Vote,
|
||||
permission: PERMS.POLLS_VIEW,
|
||||
matchPrefixes: ["/admin/polls"],
|
||||
},
|
||||
{
|
||||
href: "/admin/guilds",
|
||||
labelKey: "guilds",
|
||||
icon: UsersRound,
|
||||
permission: PERMS.USERS_VIEW,
|
||||
matchPrefixes: ["/admin/guilds"],
|
||||
},
|
||||
{
|
||||
href: "/admin/tickets",
|
||||
labelKey: "tickets",
|
||||
icon: Ticket,
|
||||
permission: PERMS.TICKETS_VIEW,
|
||||
matchPrefixes: ["/admin/tickets", "/admin/help-tickets"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "usersAndAccess",
|
||||
icon: Users,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/users",
|
||||
labelKey: "users",
|
||||
icon: Users,
|
||||
permission: PERMS.USERS_VIEW,
|
||||
matchPrefixes: ["/admin/users"],
|
||||
matchExcludePrefixes: ["/admin/users/multi-accounts"],
|
||||
},
|
||||
{
|
||||
href: "/admin/users/multi-accounts",
|
||||
labelKey: "multiAccounts",
|
||||
icon: Users,
|
||||
permission: PERMS.USERS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/online",
|
||||
labelKey: "onlineUsers",
|
||||
icon: Wifi,
|
||||
permission: PERMS.USERS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/applications",
|
||||
labelKey: "applications",
|
||||
icon: ClipboardList,
|
||||
permission: PERMS.USERS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/teams",
|
||||
labelKey: "staffAccess",
|
||||
icon: KeyRound,
|
||||
permission: [
|
||||
PERMS.USERS_VIEW,
|
||||
PERMS.PERMISSIONS_MANAGE,
|
||||
PERMS.SETTINGS_VIEW,
|
||||
],
|
||||
matchPrefixes: [
|
||||
"/admin/teams",
|
||||
"/admin/permissions",
|
||||
"/admin/housekeeping",
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/bans",
|
||||
labelKey: "bans",
|
||||
icon: Ban,
|
||||
permission: PERMS.BANS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/ip",
|
||||
labelKey: "ipManagement",
|
||||
icon: Shield,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/vpn",
|
||||
labelKey: "vpn",
|
||||
icon: Shield,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/wordfilter",
|
||||
labelKey: "wordFilter",
|
||||
icon: Filter,
|
||||
permission: PERMS.WORDFILTER_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/moderation",
|
||||
labelKey: "moderation",
|
||||
icon: Gavel,
|
||||
permission: PERMS.MODERATION_VIEW,
|
||||
matchPrefixes: ["/admin/moderation"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "economy",
|
||||
icon: ShoppingCart,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/catalog",
|
||||
labelKey: "catalog",
|
||||
icon: Store,
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
matchPrefixes: ["/admin/catalog"],
|
||||
},
|
||||
{
|
||||
href: "/admin/catalog/maintenance",
|
||||
labelKey: "catalogMaintenance",
|
||||
icon: Wrench,
|
||||
permission: PERMS.CATALOG_EDIT,
|
||||
matchPrefixes: ["/admin/catalog/maintenance"],
|
||||
},
|
||||
{
|
||||
href: "/admin/items",
|
||||
labelKey: "itemsBase",
|
||||
icon: Package,
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
matchPrefixes: ["/admin/items"],
|
||||
},
|
||||
{
|
||||
href: "/admin/rare-values",
|
||||
labelKey: "rareValues",
|
||||
icon: Sparkles,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/badges",
|
||||
labelKey: "badges",
|
||||
icon: BadgeCheck,
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/achievements",
|
||||
labelKey: "achievements",
|
||||
icon: Trophy,
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/sounds",
|
||||
labelKey: "sounds",
|
||||
icon: Volume2,
|
||||
permission: PERMS.CATALOG_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/shop",
|
||||
labelKey: "shop",
|
||||
icon: ShoppingCart,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/marketplace",
|
||||
labelKey: "marketplace",
|
||||
icon: Store,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/transactions",
|
||||
labelKey: "transactions",
|
||||
icon: Activity,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/vouchers",
|
||||
labelKey: "vouchers",
|
||||
icon: Ticket,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/subscriptions",
|
||||
labelKey: "subscriptions",
|
||||
icon: ClipboardList,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/calendar",
|
||||
labelKey: "calendar",
|
||||
icon: CalendarDays,
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
matchPrefixes: ["/admin/calendar"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "radio",
|
||||
icon: Radio,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/radio",
|
||||
labelKey: "radio",
|
||||
icon: Radio,
|
||||
permission: PERMS.RADIO_VIEW,
|
||||
matchPrefixes: ["/admin/radio"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "system",
|
||||
icon: Settings,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/settings",
|
||||
labelKey: "settings",
|
||||
icon: Cog,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/menu",
|
||||
labelKey: "adminMenu",
|
||||
icon: ListOrdered,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/theme",
|
||||
labelKey: "theme",
|
||||
icon: Sparkles,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/maintenance",
|
||||
labelKey: "maintenance",
|
||||
icon: Wrench,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/favicon",
|
||||
labelKey: "favicon",
|
||||
icon: Image,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/emulator",
|
||||
labelKey: "emulator",
|
||||
icon: Server,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/email-templates",
|
||||
labelKey: "emailTemplates",
|
||||
icon: FileText,
|
||||
permission: PERMS.PAGES_VIEW,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "tools",
|
||||
icon: Wrench,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/commandocentrum",
|
||||
labelKey: "commandocentrum",
|
||||
icon: Terminal,
|
||||
permission: PERMS.RCON_EXECUTE,
|
||||
},
|
||||
{
|
||||
href: "/admin/rooms",
|
||||
labelKey: "rooms",
|
||||
icon: Store,
|
||||
permission: PERMS.ROOMS_VIEW,
|
||||
matchPrefixes: ["/admin/rooms"],
|
||||
},
|
||||
{
|
||||
href: "/admin/studio",
|
||||
labelKey: "studio",
|
||||
icon: Palette,
|
||||
permission: PERMS.ASSETS_IMPORT,
|
||||
matchPrefixes: ["/admin/studio"],
|
||||
},
|
||||
{
|
||||
href: "/admin/translations",
|
||||
labelKey: "translations",
|
||||
icon: Languages,
|
||||
permission: PERMS.SETTINGS_VIEW,
|
||||
matchPrefixes: ["/admin/translations"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "monitoring",
|
||||
icon: Monitor,
|
||||
items: [
|
||||
{
|
||||
href: "/admin/logs",
|
||||
labelKey: "logs",
|
||||
icon: FileText,
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
matchPrefixes: ["/admin/logs"],
|
||||
matchExcludePrefixes: [
|
||||
"/admin/logs/audit",
|
||||
"/admin/logs/chat",
|
||||
"/admin/logs/commands",
|
||||
"/admin/logs/trades",
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/logs/audit",
|
||||
labelKey: "auditLog",
|
||||
icon: ClipboardList,
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/logs/chat",
|
||||
labelKey: "chatLog",
|
||||
icon: FileText,
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/logs/commands",
|
||||
labelKey: "commandLog",
|
||||
icon: Terminal,
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/logs/trades",
|
||||
labelKey: "tradeLog",
|
||||
icon: Activity,
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
},
|
||||
{
|
||||
href: "/admin/analytics",
|
||||
labelKey: "analytics",
|
||||
icon: Activity,
|
||||
permission: PERMS.ANALYTICS_VIEW,
|
||||
matchPrefixes: ["/admin/analytics"],
|
||||
},
|
||||
{
|
||||
href: "/admin/devops",
|
||||
labelKey: "devops",
|
||||
icon: Server,
|
||||
permission: PERMS.DEVOPS_VIEW,
|
||||
matchPrefixes: ["/admin/devops"],
|
||||
},
|
||||
{
|
||||
href: "/admin/alerts",
|
||||
labelKey: "alerts",
|
||||
icon: AlertTriangle,
|
||||
permission: PERMS.NOTIFICATIONS_VIEW,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Whether a nav item should be visible for the given permission set. */
|
||||
export function navItemIsAllowed(
|
||||
item: AdminNavItem,
|
||||
opts: { isSuperAdmin: boolean; has: (slug: string) => boolean },
|
||||
): boolean {
|
||||
if (opts.isSuperAdmin) return true;
|
||||
if (!item.permission) return true;
|
||||
const needed = Array.isArray(item.permission)
|
||||
? item.permission
|
||||
: [item.permission];
|
||||
return needed.some((slug) => opts.has(slug));
|
||||
}
|
||||
|
||||
/** Collect every ACL slug referenced by the sidebar (for layout gating). */
|
||||
export function collectNavPermissionSlugs(): string[] {
|
||||
const slugs = new Set<string>();
|
||||
for (const group of ADMIN_NAV_GROUPS) {
|
||||
for (const item of group.items) {
|
||||
if (!item.permission) continue;
|
||||
const needed = Array.isArray(item.permission)
|
||||
? item.permission
|
||||
: [item.permission];
|
||||
for (const slug of needed) slugs.add(slug);
|
||||
}
|
||||
}
|
||||
return [...slugs];
|
||||
}
|
||||
@@ -2,16 +2,78 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROUTES: Array<[string, string]> = [
|
||||
["moderation", "PERMS.MODERATION_VIEW"],
|
||||
["moderation/actions", "PERMS.MODERATION_EDIT"],
|
||||
["moderation/cfh", "PERMS.MODERATION_VIEW"],
|
||||
["logs/audit", "PERMS.LOGS_VIEW"],
|
||||
["analytics", "PERMS.ANALYTICS_VIEW"],
|
||||
["devops", "PERMS.DEVOPS_VIEW"],
|
||||
["online", "PERMS.USERS_VIEW"],
|
||||
["commandocentrum", "PERMS.RCON_EXECUTE"],
|
||||
["users/edit/[id]", "PERMS.USERS_EDIT"],
|
||||
["settings", "PERMS.SETTINGS_VIEW"],
|
||||
["theme", "PERMS.SETTINGS_VIEW"],
|
||||
["emulator", "PERMS.SETTINGS_VIEW"],
|
||||
["bans", "PERMS.BANS_VIEW"],
|
||||
["wordfilter", "PERMS.WORDFILTER_VIEW"],
|
||||
["articles", "PERMS.NEWS_VIEW"],
|
||||
["shop", "PERMS.SHOP_VIEW"],
|
||||
["transactions", "PERMS.SHOP_VIEW"],
|
||||
["vouchers", "PERMS.SHOP_VIEW"],
|
||||
["marketplace", "PERMS.SHOP_VIEW"],
|
||||
["vpn", "PERMS.SETTINGS_VIEW"],
|
||||
["ip", "PERMS.SETTINGS_VIEW"],
|
||||
["maintenance", "PERMS.SETTINGS_VIEW"],
|
||||
["alerts", "PERMS.NOTIFICATIONS_VIEW"],
|
||||
["tags", "PERMS.PAGES_VIEW"],
|
||||
["ads", "PERMS.PAGES_VIEW"],
|
||||
["media", "PERMS.PAGES_VIEW"],
|
||||
["photos", "PERMS.PAGES_VIEW"],
|
||||
["badges", "PERMS.CATALOG_VIEW"],
|
||||
["teams", "PERMS.USERS_VIEW"],
|
||||
["applications", "PERMS.USERS_VIEW"],
|
||||
["guilds", "PERMS.USERS_VIEW"],
|
||||
["tickets", "PERMS.TICKETS_VIEW"],
|
||||
["help-tickets", "PERMS.TICKETS_VIEW"],
|
||||
["permissions", "PERMS.PERMISSIONS_MANAGE"],
|
||||
["housekeeping", "PERMS.SETTINGS_VIEW"],
|
||||
["logs", "PERMS.LOGS_VIEW"],
|
||||
["catalog", "PERMS.CATALOG_VIEW"],
|
||||
["items", "PERMS.CATALOG_VIEW"],
|
||||
["items/[id]", "PERMS.CATALOG_VIEW"],
|
||||
["rooms/edit/[id]", "PERMS.ROOMS_EDIT"],
|
||||
];
|
||||
|
||||
const MOD_ROUTES: Array<[string, string]> = [
|
||||
["", "requireMod"],
|
||||
["cfh", "PERMS.MOD_CFH_VIEW"],
|
||||
["actions", "PERMS.MOD_ACTIONS"],
|
||||
["bans", "PERMS.MOD_BANS_VIEW"],
|
||||
["tickets", "PERMS.MOD_TICKETS_VIEW"],
|
||||
["help-tickets", "PERMS.MOD_TICKETS_VIEW"],
|
||||
["users", "PERMS.MOD_USERS_VIEW"],
|
||||
["team", "PERMS.MOD_TEAM_VIEW"],
|
||||
];
|
||||
|
||||
const ACTION_GATES: Array<[string, string]> = [
|
||||
["admin-settings.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["admin-theme.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["admin-emulator.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["admin-bans.ts", "PERMS.USERS_BAN"],
|
||||
["admin-wordfilter.ts", "PERMS.WORDFILTER_EDIT"],
|
||||
["admin-articles.ts", "PERMS.NEWS_EDIT"],
|
||||
["admin-shop.ts", "PERMS.SHOP_EDIT"],
|
||||
["admin-radio-autodj.ts", "PERMS.RADIO_EDIT"],
|
||||
["catalog.ts", "PERMS.CATALOG_EDIT"],
|
||||
["items-base.ts", "PERMS.CATALOG_EDIT"],
|
||||
["rooms.ts", "PERMS.ROOMS_EDIT"],
|
||||
["admin-vpn.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["admin-ads.ts", "PERMS.PAGES_EDIT"],
|
||||
["admin-vouchers.ts", "PERMS.SHOP_EDIT"],
|
||||
["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"],
|
||||
["commandocentrum.ts", "PERMS.RCON_EXECUTE"],
|
||||
["translations.ts", "PERMS.SETTINGS_EDIT"],
|
||||
["tickets.ts", "PERMS.TICKETS_EDIT"],
|
||||
["admin-help-tickets.ts", "PERMS.TICKETS_EDIT"],
|
||||
["permissions.ts", "PERMS.PERMISSIONS_MANAGE"],
|
||||
@@ -20,7 +82,32 @@ const ACTION_GATES: Array<[string, string]> = [
|
||||
["moderation.ts", "PERMS.MODERATION_EDIT"],
|
||||
];
|
||||
|
||||
describe("administration backend contract", () => {
|
||||
describe("admin operations route contract", () => {
|
||||
it.each(ROUTES)("provides and guards /admin/%s", (route, permission) => {
|
||||
const path = `src/app/admin/${route}/page.tsx`;
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
expect(readFileSync(path, "utf8"), path).toContain(permission);
|
||||
});
|
||||
|
||||
it.each(MOD_ROUTES)("provides and guards /mod/%s", (route, permission) => {
|
||||
const path =
|
||||
route === "" ? "src/app/mod/page.tsx" : `src/app/mod/${route}/page.tsx`;
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
const source = readFileSync(path, "utf8");
|
||||
const layout = readFileSync("src/app/mod/layout.tsx", "utf8");
|
||||
if (permission === "requireMod") {
|
||||
expect(layout).toContain("requireMod");
|
||||
} else {
|
||||
expect(source).toContain(permission);
|
||||
}
|
||||
});
|
||||
|
||||
it("guards radio section via layout", () => {
|
||||
const path = "src/app/admin/radio/layout.tsx";
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
expect(readFileSync(path, "utf8"), path).toContain("PERMS.RADIO_VIEW");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["analytics/export", "PERMS.ANALYTICS_EXPORT"],
|
||||
["devops/health", "PERMS.DEVOPS_VIEW"],
|
||||
@@ -38,7 +125,7 @@ describe("administration backend contract", () => {
|
||||
expect(source).not.toMatch(/await requireStaffRateLimited\(\)/);
|
||||
});
|
||||
|
||||
it("requires explicit permissions for every administration action", () => {
|
||||
it("has no requireStaff left in admin action modules", () => {
|
||||
const dir = "src/actions";
|
||||
const offenders: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
@@ -51,8 +138,24 @@ describe("administration backend contract", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the shared ops health probe behind the API", () => {
|
||||
it("caches analytics full-scans via redisCache", () => {
|
||||
for (const path of [
|
||||
"src/app/admin/analytics/page.tsx",
|
||||
"src/app/admin/analytics/activity/page.tsx",
|
||||
"src/app/admin/analytics/economy/page.tsx",
|
||||
]) {
|
||||
expect(readFileSync(path, "utf8"), path).toContain("redisCache");
|
||||
}
|
||||
});
|
||||
|
||||
it("shares ops health probe across CC / DevOps / API", () => {
|
||||
expect(existsSync("src/lib/admin/ops-health.ts")).toBe(true);
|
||||
expect(
|
||||
readFileSync("src/app/admin/commandocentrum/page.tsx", "utf8"),
|
||||
).toContain("fetchOpsHealth");
|
||||
expect(readFileSync("src/app/admin/devops/page.tsx", "utf8")).toContain(
|
||||
"fetchOpsHealth",
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/app/api/admin/devops/health/route.ts", "utf8"),
|
||||
).toContain("fetchOpsHealth");
|
||||
|
||||
@@ -3,16 +3,31 @@ import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOTS = [
|
||||
"src/app/admin",
|
||||
"src/components/admin",
|
||||
"src/app/ase",
|
||||
"src/app/admin-next",
|
||||
"src/features/housekeeping",
|
||||
];
|
||||
const GRAPHICAL_ALLOWLIST = [
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/import/clone/import-clone-client.tsx",
|
||||
"src/components/admin/catalog/items-shop-preview.tsx",
|
||||
"src/components/admin/media-grid.tsx",
|
||||
];
|
||||
|
||||
const DATA_COLOR_ALLOWLIST: readonly string[] = [];
|
||||
const DATA_COLOR_ALLOWLIST = [
|
||||
"src/app/admin/alerts/page.tsx",
|
||||
"src/app/admin/banners/banners-manager.tsx",
|
||||
"src/app/admin/events/events-table.tsx",
|
||||
"src/app/admin/events/types/event-types-manager.tsx",
|
||||
"src/app/admin/favicon/favicon-generator.tsx",
|
||||
"src/app/admin/help-questions/new/page.tsx",
|
||||
"src/app/admin/help-questions/[id]/page.tsx",
|
||||
"src/app/admin/prefixes/prefixes-client.tsx",
|
||||
"src/app/admin/tags/page.tsx",
|
||||
"src/app/admin/teams/page.tsx",
|
||||
"src/app/admin/theme/page.tsx",
|
||||
];
|
||||
|
||||
const PUBLIC_STRUCTURAL_TOKEN =
|
||||
/var\(--(?:color-(?:background|surface|dropdown|navbar|navbar-text|text|text-muted|primary|primary-hover)|border-subtle|border-color)\)/g;
|
||||
@@ -76,11 +91,8 @@ describe("admin theme source audit", () => {
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps retained Studio workflows on semantic status and overlay colors", () => {
|
||||
const studioFiles = [
|
||||
"src/features/housekeeping/domains/hotel/pages/studio.tsx",
|
||||
];
|
||||
const violations = studioFiles.flatMap((file) => {
|
||||
it("keeps every import workflow on semantic admin status and overlay colors", () => {
|
||||
const violations = sourceFiles("src/app/admin/import").flatMap((file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const risky =
|
||||
source.match(/rgba?\([^)]*\)|(?:bg-black|text-white)(?:\/\d+)?/g) ?? [];
|
||||
|
||||
@@ -3,23 +3,12 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ACL management contract", () => {
|
||||
it("uses normalized ACL persistence and the permissions.manage guard", () => {
|
||||
const wrapper = readFileSync("src/actions/permissions.ts", "utf8");
|
||||
const service = readFileSync(
|
||||
"src/features/housekeeping/domains/system/services/mutations.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(wrapper).toContain("PERMS.PERMISSIONS_MANAGE");
|
||||
expect(wrapper).toContain("adminAction");
|
||||
expect(wrapper).toContain("access.permissions.update");
|
||||
expect(wrapper).toContain("access.permissions.repair");
|
||||
expect(service).toContain('import "server-only"');
|
||||
expect(service).toContain("AclModelPermission");
|
||||
expect(service).not.toContain(
|
||||
"export const systemProductionMutationAdapter",
|
||||
);
|
||||
expect(service).not.toContain("legacyMessage");
|
||||
expect(service).not.toContain("websiteHousekeepingPermissions");
|
||||
expect(service).not.toContain("websiteTeams");
|
||||
const source = readFileSync("src/actions/permissions.ts", "utf8");
|
||||
expect(source).toContain("PERMS.PERMISSIONS_MANAGE");
|
||||
expect(source).toContain("adminAction");
|
||||
expect(source).toContain("AclModelPermission");
|
||||
expect(source).not.toContain("websiteHousekeepingPermissions");
|
||||
expect(source).not.toContain("websiteTeams");
|
||||
});
|
||||
|
||||
it("ships an idempotent ACL completion migration", () => {
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel";
|
||||
|
||||
export type FieldType =
|
||||
| "text"
|
||||
| "password"
|
||||
| "url"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "textarea"
|
||||
| "select";
|
||||
|
||||
export type SettingsGroupIcon =
|
||||
| "building"
|
||||
| "image"
|
||||
| "gamepad"
|
||||
| "music"
|
||||
| "shield"
|
||||
| "link"
|
||||
| "user-plus";
|
||||
|
||||
export interface ManagedField {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type?: FieldType;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface SettingsGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: SettingsGroupIcon;
|
||||
description?: string;
|
||||
fields: ManagedField[];
|
||||
}
|
||||
|
||||
/** Keys managed by the structured CMS Settings form (not theme / VPN / maintenance pages). */
|
||||
export const SETTINGS_GROUPS: SettingsGroup[] = [
|
||||
{
|
||||
id: "identity",
|
||||
title: "Hotel identity",
|
||||
icon: "building",
|
||||
description: "Name, branding and defaults shown across the site.",
|
||||
fields: [
|
||||
{
|
||||
key: "cms_logo",
|
||||
label: "Logo URL",
|
||||
description: "Header logo path or absolute URL.",
|
||||
type: "url",
|
||||
placeholder: "/api/media/logo/…",
|
||||
},
|
||||
{
|
||||
key: "cms_favicon",
|
||||
label: "Favicon URL",
|
||||
type: "url",
|
||||
placeholder: "/favicon.svg",
|
||||
},
|
||||
{
|
||||
key: "cms_header",
|
||||
label: "Header background",
|
||||
type: "url",
|
||||
placeholder: "/assets/images/background.png",
|
||||
defaultValue: "/assets/images/background.png",
|
||||
},
|
||||
{
|
||||
key: "default_dark",
|
||||
label: "Dark mode by default",
|
||||
description: "New visitors start in dark mode unless they override it.",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "min_staff_rank",
|
||||
label: "Minimum staff rank",
|
||||
description:
|
||||
"Minimum rank treated as staff (admin lists and public staff page).",
|
||||
type: "number",
|
||||
defaultValue: "7",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "imaging",
|
||||
title: "Avatars & badges",
|
||||
icon: "image",
|
||||
description: "Imaging endpoints used for avatars and badge icons.",
|
||||
fields: [
|
||||
{
|
||||
key: "habbo_imaging_url",
|
||||
label: "Public imager URL",
|
||||
type: "url",
|
||||
defaultValue: "/imaging",
|
||||
description:
|
||||
"Public avatar endpoint (relative or absolute). Leave as /imaging to serve from this site.",
|
||||
},
|
||||
{
|
||||
key: "imaging_use_habbo_fallback",
|
||||
label: "Use Habbo.com as avatar fallback (legacy proxy)",
|
||||
type: "boolean",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
key: "badge_base_url",
|
||||
label: "Badge icons base URL",
|
||||
type: "url",
|
||||
placeholder: "/swf/c_images/album1584",
|
||||
},
|
||||
{
|
||||
key: "badges_path",
|
||||
label: "Badges path (rares page)",
|
||||
type: "url",
|
||||
},
|
||||
{
|
||||
key: "furniture_icons_path",
|
||||
label: "Furniture icons path",
|
||||
type: "url",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "client",
|
||||
title: "Nitro client",
|
||||
icon: "gamepad",
|
||||
description: "Game client loaded at /client.",
|
||||
fields: [
|
||||
{
|
||||
key: "nitro_client_url",
|
||||
label: "Nitro client URL",
|
||||
description: "Absolute or same-origin URL for the Nitro iframe.",
|
||||
type: "url",
|
||||
placeholder: "https://…/client/",
|
||||
},
|
||||
{
|
||||
key: "nitro_files_root",
|
||||
label: "Nitro-Files root (server path)",
|
||||
description:
|
||||
"Filesystem root used when importing furni / writing live assets.",
|
||||
type: "text",
|
||||
placeholder: "E:\\path\\to\\Nitro-Files",
|
||||
},
|
||||
{
|
||||
key: "gamedata_root",
|
||||
label: "Production Gamedata root (server path)",
|
||||
description:
|
||||
"Filesystem root served at /gamedata. Auto-detected as /var/www/Gamedata when present.",
|
||||
type: "text",
|
||||
placeholder: "/var/www/Gamedata",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "assets",
|
||||
title: "Game assets",
|
||||
icon: "music",
|
||||
description: "Public URLs and overrides for SWF / Nitro asset packages.",
|
||||
fields: [
|
||||
{
|
||||
key: "furnidata_translate_enabled",
|
||||
label: "Translate furniture names",
|
||||
description:
|
||||
"Rebuild FurnitureData_<lang>.json with translated names after each import. Disable to skip the LibreTranslate / deep-clone work and save CPU & RAM when you don't need localized furniture names. You can still rebuild on demand via the import 'build languages' action.",
|
||||
type: "boolean",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
key: "habbo_gamedata_hotel",
|
||||
label: "Habbo hotel for furni names",
|
||||
description:
|
||||
"Official Habbo locale used for catalog name suggestions, furni import enrichment, and badge text lookup (furnidata / external texts).",
|
||||
type: "select",
|
||||
defaultValue: "it",
|
||||
options: HABBO_GAMEDATA_HOTELS.map((h) => ({
|
||||
value: h.value,
|
||||
label: h.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "soundtrack_base_url",
|
||||
label: "Song disks MP3 base URL",
|
||||
type: "url",
|
||||
defaultValue: "/swf/dcr/hof_furni/mp3/",
|
||||
},
|
||||
{
|
||||
key: "furni_data_mirror_path",
|
||||
label: "FurnitureData mirror path",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "figure_swf_base_url",
|
||||
label: "Figure SWF base URL (override)",
|
||||
type: "url",
|
||||
},
|
||||
{
|
||||
key: "effect_swf_base_url",
|
||||
label: "Effect SWF base URL (override)",
|
||||
type: "url",
|
||||
},
|
||||
{
|
||||
key: "pet_swf_base_url",
|
||||
label: "Pet SWF base URL (override)",
|
||||
type: "url",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "radio",
|
||||
title: "Radio",
|
||||
icon: "music",
|
||||
description: "Public radio player and DJ monitoring feeds.",
|
||||
fields: [
|
||||
{
|
||||
key: "radio_enabled",
|
||||
label: "Enable radio player",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "radio_name",
|
||||
label: "Radio display name",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "radio_stream_url",
|
||||
label: "Stream URL",
|
||||
type: "url",
|
||||
},
|
||||
{
|
||||
key: "radio_now_playing_api_url",
|
||||
label: "Now-playing API URL",
|
||||
type: "url",
|
||||
},
|
||||
{
|
||||
key: "radio_listeners_api_url",
|
||||
label: "Listeners API URL",
|
||||
type: "url",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
title: "Security & registration",
|
||||
icon: "shield",
|
||||
description: "Account limits, captcha and staff 2FA.",
|
||||
fields: [
|
||||
{
|
||||
key: "force_staff_2fa",
|
||||
label: "Require 2FA for staff",
|
||||
description: "Staff without 2FA are redirected to set it up.",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "require_email_verification",
|
||||
label: "Require email verification",
|
||||
description:
|
||||
"Block login until the account email is verified (accounts without email are unaffected).",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "max_accounts_per_ip",
|
||||
label: "Max accounts per IP",
|
||||
description: "0 = unlimited.",
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "captcha_provider",
|
||||
label: "Captcha provider",
|
||||
description: "none | turnstile | recaptcha | hcaptcha",
|
||||
type: "text",
|
||||
defaultValue: "none",
|
||||
},
|
||||
{
|
||||
key: "turnstile_site_key",
|
||||
label: "Turnstile site key",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "hcaptcha_site_key",
|
||||
label: "hCaptcha site key",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "recaptcha_site_key",
|
||||
label: "reCAPTCHA site key",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "abuse_guard_enabled",
|
||||
label: "Enable abuse guard",
|
||||
type: "boolean",
|
||||
defaultValue: "0",
|
||||
},
|
||||
{
|
||||
key: "abuse_guard_threshold",
|
||||
label: "Abuse guard threshold",
|
||||
type: "number",
|
||||
defaultValue: "200",
|
||||
},
|
||||
{
|
||||
key: "abuse_guard_window_seconds",
|
||||
label: "Abuse guard window (seconds)",
|
||||
type: "number",
|
||||
defaultValue: "10",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "misc",
|
||||
title: "Other",
|
||||
icon: "link",
|
||||
description: "Extra hotel features.",
|
||||
fields: [
|
||||
{
|
||||
key: "drawbadge.price",
|
||||
label: "Draw-badge price",
|
||||
type: "number",
|
||||
defaultValue: "0",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const MANAGED_SETTING_KEYS: string[] = SETTINGS_GROUPS.flatMap((g) =>
|
||||
g.fields.map((f) => f.key),
|
||||
);
|
||||
|
||||
export const BOOLEAN_KEYS = new Set(
|
||||
SETTINGS_GROUPS.flatMap((g) =>
|
||||
g.fields.filter((f) => f.type === "boolean").map((f) => f.key),
|
||||
),
|
||||
);
|
||||
|
||||
export const FIELD_DEFAULTS: Record<string, string> = Object.fromEntries(
|
||||
SETTINGS_GROUPS.flatMap((g) =>
|
||||
g.fields
|
||||
.filter((f) => f.defaultValue != null)
|
||||
.map((f) => [f.key, f.defaultValue as string]),
|
||||
),
|
||||
);
|
||||
@@ -1,14 +1,9 @@
|
||||
// @ts-nocheck
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
anyCapability,
|
||||
type HousekeepingCapabilityContext,
|
||||
} from "@/features/housekeeping/foundation/contracts";
|
||||
import { redirectSafe } from "@/lib/foundation/security";
|
||||
import { canAccess, getAdminContext } from "@/lib/permissions";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
requireHousekeepingCapability,
|
||||
requireMod,
|
||||
requireModPermission,
|
||||
requirePermission,
|
||||
@@ -75,23 +70,6 @@ describe("requirePermission", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireHousekeepingCapability", () => {
|
||||
it("reuses an already-created capability context without reloading permissions", async () => {
|
||||
const context: HousekeepingCapabilityContext = {
|
||||
actor: { id: 1, username: "admin", rank: 0 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => slug === "admin.users.view",
|
||||
hasAny: (...slugs) => slugs.includes("admin.users.view"),
|
||||
hasAll: (...slugs) => slugs.every((slug) => slug === "admin.users.view"),
|
||||
};
|
||||
|
||||
await expect(
|
||||
requireHousekeepingCapability(anyCapability("admin.users.view"), context),
|
||||
).resolves.toBe(context);
|
||||
expect(getAdminContext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireMod", () => {
|
||||
it("allows with MOD_DASHBOARD", async () => {
|
||||
vi.mocked(getAdminContext).mockResolvedValue({
|
||||
@@ -139,6 +117,9 @@ describe("requireStaffRateLimited", () => {
|
||||
vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
|
||||
vi.mocked(rateLimit).mockResolvedValue({ ok: false });
|
||||
await requireStaffRateLimited();
|
||||
expect(redirectSafe).toHaveBeenCalledWith("/ase?error=ratelimit", "/ase");
|
||||
expect(redirectSafe).toHaveBeenCalledWith(
|
||||
"/admin?error=ratelimit",
|
||||
"/admin",
|
||||
);
|
||||
});
|
||||
});
|
||||
+5
-23
@@ -1,9 +1,3 @@
|
||||
import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization";
|
||||
import type {
|
||||
CapabilityRequirement,
|
||||
HousekeepingCapabilityContext,
|
||||
} from "@/features/housekeeping/foundation/contracts";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import { redirectSafe } from "@/lib/foundation/security";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
@@ -13,18 +7,6 @@ export interface StaffUser {
|
||||
rank: number;
|
||||
username: string;
|
||||
}
|
||||
export async function requireHousekeepingCapability(
|
||||
requirement: CapabilityRequirement,
|
||||
context?: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingCapabilityContext> {
|
||||
const capabilityContext =
|
||||
context ?? (await getHousekeepingCapabilityContext());
|
||||
const authorization = authorizeHousekeeping(capabilityContext, requirement);
|
||||
|
||||
if (!authorization.ok) redirectSafe("/ase", "/ase");
|
||||
|
||||
return capabilityContext;
|
||||
}
|
||||
|
||||
export async function requireStaff(): Promise<StaffUser> {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
@@ -48,7 +30,7 @@ export async function requirePermission(
|
||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank))
|
||||
redirectSafe("/", "/");
|
||||
if (!canAccess(permissions, permission, session.user.rank))
|
||||
redirectSafe("/ase", "/ase");
|
||||
redirectSafe("/admin", "/admin");
|
||||
return {
|
||||
id: session.user.id,
|
||||
rank: session.user.rank,
|
||||
@@ -62,7 +44,7 @@ export async function requirePermissionRateLimited(
|
||||
const staff = await requirePermission(permission);
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/ase?error=ratelimit", "/ase");
|
||||
redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
return staff;
|
||||
}
|
||||
|
||||
@@ -70,13 +52,13 @@ export async function requireStaffRateLimited(): Promise<StaffUser> {
|
||||
const staff = await requireStaff();
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/ase?error=ratelimit", "/ase");
|
||||
redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
return staff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mod-lite gate: needs any mod.* / moderation ACL, not admin.dashboard.
|
||||
* Retained for capability-compatible action adapters used by mid-rank staff.
|
||||
* Use for `/mod` layout so mid-ranks can access without full housekeeping.
|
||||
*/
|
||||
export async function requireMod(): Promise<StaffUser> {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
@@ -106,6 +88,6 @@ export async function requireModPermission(
|
||||
const { permissions } = await getAdminContext();
|
||||
const needed = Array.isArray(permission) ? permission : [permission];
|
||||
const ok = needed.some((slug) => canAccess(permissions, slug, staff.rank));
|
||||
if (!ok) redirectSafe("/ase", "/ase");
|
||||
if (!ok) redirectSafe("/mod", "/mod");
|
||||
return staff;
|
||||
}
|
||||
@@ -1,40 +1,41 @@
|
||||
import "server-only";
|
||||
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import type { OnlineUser } from "@/components/admin/dashboard";
|
||||
import { db, User } from "@/lib/db";
|
||||
|
||||
interface OpsOnlineUserRow {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly look: string;
|
||||
readonly lastOnline: number;
|
||||
}
|
||||
/** Shared online roster used by CommandoCentrum (and linked from other ops hubs). */
|
||||
export async function fetchOpsOnlineUsers(
|
||||
limit = 40,
|
||||
): Promise<{ count: number; users: OnlineUser[] }> {
|
||||
const [countRow, onlineUsersRaw] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
lastOnline: User.lastOnline,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.orderBy(desc(User.lastOnline))
|
||||
.limit(limit)
|
||||
.then((rows) => rows)
|
||||
.catch(
|
||||
() =>
|
||||
[] as Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
look: string;
|
||||
lastOnline: number;
|
||||
}>,
|
||||
),
|
||||
]);
|
||||
|
||||
function loadOnlineCount(): Promise<number> {
|
||||
return db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0);
|
||||
}
|
||||
|
||||
function loadOnlineRows(limit: number): Promise<OpsOnlineUserRow[]> {
|
||||
return db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
lastOnline: User.lastOnline,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.orderBy(desc(User.lastOnline))
|
||||
.limit(limit)
|
||||
.then((rows) => rows);
|
||||
}
|
||||
|
||||
function onlineRoster(countRow: number, onlineUsersRaw: OpsOnlineUserRow[]) {
|
||||
const users: OnlineUser[] = onlineUsersRaw.map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
@@ -46,25 +47,3 @@ function onlineRoster(countRow: number, onlineUsersRaw: OpsOnlineUserRow[]) {
|
||||
|
||||
return { count: countRow, users };
|
||||
}
|
||||
|
||||
/** Fail-aware roster for guarded System workflows. */
|
||||
export async function fetchOpsOnlineUsersStrict(
|
||||
limit = 40,
|
||||
): Promise<{ count: number; users: OnlineUser[] }> {
|
||||
const [countRow, onlineUsersRaw] = await Promise.all([
|
||||
loadOnlineCount(),
|
||||
loadOnlineRows(limit),
|
||||
]);
|
||||
return onlineRoster(countRow, onlineUsersRaw);
|
||||
}
|
||||
|
||||
/** Shared tolerant roster used by the legacy CommandoCentrum. */
|
||||
export async function fetchOpsOnlineUsers(
|
||||
limit = 40,
|
||||
): Promise<{ count: number; users: OnlineUser[] }> {
|
||||
const [countRow, onlineUsersRaw] = await Promise.all([
|
||||
loadOnlineCount().catch(() => 0),
|
||||
loadOnlineRows(limit).catch(() => []),
|
||||
]);
|
||||
return onlineRoster(countRow, onlineUsersRaw);
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
ne,
|
||||
or,
|
||||
type SQL,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/mysql-core";
|
||||
import { calcPagination } from "@/lib/admin-helpers";
|
||||
@@ -37,16 +36,11 @@ export interface TicketInboxRow {
|
||||
export interface FetchTicketInboxOptions {
|
||||
page: number;
|
||||
perPage: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
type?: TicketInboxTypeFilter;
|
||||
/** Default true: only non-closed CMS + open help-center. */
|
||||
openOnly?: boolean;
|
||||
base?: "admin" | "mod";
|
||||
sort?: "id" | "subject" | "status" | "updatedAt";
|
||||
order?: "asc" | "desc";
|
||||
/** Fail-closed, DB-paged boundary for security-sensitive read models. */
|
||||
strict?: boolean;
|
||||
}
|
||||
|
||||
function toSortMs(value: Date | string | null | undefined): number {
|
||||
@@ -60,6 +54,7 @@ async function fetchCmsCandidates(
|
||||
limit: number,
|
||||
search: string,
|
||||
openOnly: boolean,
|
||||
base: "admin" | "mod",
|
||||
): Promise<TicketInboxRow[]> {
|
||||
const Creator = alias(User, "inbox_ticket_creator");
|
||||
const conditions: SQL[] = [];
|
||||
@@ -101,7 +96,7 @@ async function fetchCmsCandidates(
|
||||
.limit(limit)
|
||||
.catch(() => []);
|
||||
|
||||
const prefix = "/ase/people/support/tickets";
|
||||
const prefix = base === "mod" ? "/mod/tickets" : "/admin/tickets";
|
||||
|
||||
return rows.map((row) => ({
|
||||
key: `cms-${row.id}`,
|
||||
@@ -122,6 +117,7 @@ async function fetchHelpCandidates(
|
||||
limit: number,
|
||||
search: string,
|
||||
openOnly: boolean,
|
||||
base: "admin" | "mod",
|
||||
): Promise<TicketInboxRow[]> {
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
@@ -190,7 +186,7 @@ async function fetchHelpCandidates(
|
||||
: [];
|
||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
const prefix = "/ase/people/support/help-tickets";
|
||||
const prefix = base === "mod" ? "/mod/help-tickets" : "/admin/help-tickets";
|
||||
|
||||
return rows.map((row) => ({
|
||||
key: `help-${row.id}`,
|
||||
@@ -271,209 +267,6 @@ async function countHelp(search: string, openOnly: boolean): Promise<number> {
|
||||
return Number(rows[0]?.total ?? 0);
|
||||
}
|
||||
|
||||
function strictRows<T>(result: unknown): T[] {
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid ticket inbox driver result");
|
||||
}
|
||||
return result[0] as T[];
|
||||
}
|
||||
|
||||
async function fetchStrictUnifiedTicketInbox(
|
||||
options: FetchTicketInboxOptions,
|
||||
): Promise<{
|
||||
rows: TicketInboxRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
cmsTotal: number;
|
||||
helpTotal: number;
|
||||
}> {
|
||||
const type = options.type ?? "all";
|
||||
const openOnly = options.openOnly !== false;
|
||||
const search = options.search?.trim() ?? "";
|
||||
const pattern = `%${search}%`;
|
||||
const numericCandidate = /^\d+$/.test(search) ? Number(search) : null;
|
||||
const numericSearch =
|
||||
numericCandidate !== null &&
|
||||
Number.isSafeInteger(numericCandidate) &&
|
||||
numericCandidate > 0
|
||||
? numericCandidate
|
||||
: null;
|
||||
if (
|
||||
!Number.isFinite(options.perPage) ||
|
||||
!Number.isFinite(options.page) ||
|
||||
(options.offset !== undefined && !Number.isFinite(options.offset))
|
||||
) {
|
||||
throw new Error("invalid strict ticket inbox pagination");
|
||||
}
|
||||
const perPage = Math.min(Math.max(Math.trunc(options.perPage), 1), 100);
|
||||
const requestedPage = Math.max(Math.trunc(options.page), 1);
|
||||
const offset = Math.min(
|
||||
Math.max(Math.trunc(options.offset ?? (requestedPage - 1) * perPage), 0),
|
||||
10_000,
|
||||
);
|
||||
const page = Math.floor(offset / perPage) + 1;
|
||||
|
||||
const cmsConditions: SQL[] = [];
|
||||
if (openOnly) cmsConditions.push(sql`t.status <> 'closed'`);
|
||||
if (search) {
|
||||
cmsConditions.push(sql`(
|
||||
t.subject LIKE ${pattern}
|
||||
OR t.category LIKE ${pattern}
|
||||
OR creator.username LIKE ${pattern}
|
||||
${numericSearch !== null ? sql`OR t.id = ${numericSearch}` : sql``}
|
||||
)`);
|
||||
}
|
||||
const helpConditions: SQL[] = [];
|
||||
if (openOnly) helpConditions.push(sql`h.open = 1`);
|
||||
if (search) {
|
||||
helpConditions.push(sql`(
|
||||
h.title LIKE ${pattern}
|
||||
OR h.content LIKE ${pattern}
|
||||
OR help_user.username LIKE ${pattern}
|
||||
${numericSearch !== null ? sql`OR h.id = ${numericSearch}` : sql``}
|
||||
)`);
|
||||
}
|
||||
const cmsWhere =
|
||||
cmsConditions.length > 0
|
||||
? sql`WHERE ${sql.join(cmsConditions, sql` AND `)}`
|
||||
: sql``;
|
||||
const helpWhere =
|
||||
helpConditions.length > 0
|
||||
? sql`WHERE ${sql.join(helpConditions, sql` AND `)}`
|
||||
: sql``;
|
||||
const cmsSelect = sql`
|
||||
SELECT 'cms' AS kind, t.id AS numericId, t.subject AS title,
|
||||
COALESCE(creator.username, '—') AS user, t.creator_id AS userId,
|
||||
t.status AS status, (t.status <> 'closed') AS open,
|
||||
COALESCE(UNIX_TIMESTAMP(t.updated_at), UNIX_TIMESTAMP(t.created_at), 0) * 1000 AS sortAt,
|
||||
COALESCE(t.updated_at, t.created_at) AS dateValue
|
||||
FROM website_tickets t
|
||||
LEFT JOIN users creator ON creator.id = t.creator_id
|
||||
${cmsWhere}
|
||||
`;
|
||||
const helpSelect = sql`
|
||||
SELECT 'help' AS kind, h.id AS numericId, h.title AS title,
|
||||
COALESCE(help_user.username, '—') AS user, h.user_id AS userId,
|
||||
IF(h.open = 1, 'open', 'closed') AS status, h.open AS open,
|
||||
COALESCE(UNIX_TIMESTAMP(h.updated_at), UNIX_TIMESTAMP(h.created_at), 0) * 1000 AS sortAt,
|
||||
COALESCE(h.updated_at, h.created_at) AS dateValue
|
||||
FROM website_help_center_tickets h
|
||||
LEFT JOIN users help_user ON help_user.id = h.user_id
|
||||
${helpWhere}
|
||||
`;
|
||||
const unified =
|
||||
type === "cms"
|
||||
? cmsSelect
|
||||
: type === "help"
|
||||
? helpSelect
|
||||
: sql`${cmsSelect} UNION ALL ${helpSelect}`;
|
||||
const sortColumn =
|
||||
options.sort === "id"
|
||||
? sql`ticket_rows.numericId`
|
||||
: options.sort === "subject"
|
||||
? sql`ticket_rows.title`
|
||||
: options.sort === "status"
|
||||
? sql`ticket_rows.status`
|
||||
: sql`ticket_rows.sortAt`;
|
||||
const direction = options.order === "asc" ? sql`ASC` : sql`DESC`;
|
||||
const [rowsResult, countResult] = await Promise.all([
|
||||
db.execute(sql`
|
||||
SELECT * FROM (${unified}) AS ticket_rows
|
||||
ORDER BY ${sortColumn} ${direction}, ticket_rows.kind ASC,
|
||||
ticket_rows.numericId ${direction}
|
||||
LIMIT ${perPage} OFFSET ${offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total,
|
||||
SUM(ticket_rows.kind = 'cms') AS cmsTotal,
|
||||
SUM(ticket_rows.kind = 'help') AS helpTotal
|
||||
FROM (${unified}) AS ticket_rows
|
||||
`),
|
||||
]);
|
||||
const counts = strictRows<{
|
||||
total: number | bigint;
|
||||
cmsTotal: number | bigint | null;
|
||||
helpTotal: number | bigint | null;
|
||||
}>(countResult)[0];
|
||||
if (!counts) throw new Error("ticket inbox counts unavailable");
|
||||
const total = Number(counts.total);
|
||||
const cmsTotal = Number(counts.cmsTotal ?? 0);
|
||||
const helpTotal = Number(counts.helpTotal ?? 0);
|
||||
if (
|
||||
!Number.isSafeInteger(total) ||
|
||||
total < 0 ||
|
||||
!Number.isSafeInteger(cmsTotal) ||
|
||||
cmsTotal < 0 ||
|
||||
!Number.isSafeInteger(helpTotal) ||
|
||||
helpTotal < 0
|
||||
) {
|
||||
throw new Error("invalid ticket inbox counts");
|
||||
}
|
||||
const rows = strictRows<{
|
||||
kind: TicketInboxKind;
|
||||
numericId: number | bigint;
|
||||
title: string;
|
||||
user: string;
|
||||
userId: number | null;
|
||||
status: string;
|
||||
open: number | boolean;
|
||||
sortAt: number | bigint;
|
||||
dateValue: Date | string | null;
|
||||
}>(rowsResult).map((row) => {
|
||||
const id = Number(row.numericId);
|
||||
const sortAt = Number(row.sortAt);
|
||||
if (
|
||||
(row.kind !== "cms" && row.kind !== "help") ||
|
||||
!Number.isSafeInteger(id) ||
|
||||
id <= 0 ||
|
||||
!Number.isSafeInteger(sortAt) ||
|
||||
sortAt < 0 ||
|
||||
typeof row.title !== "string" ||
|
||||
typeof row.user !== "string" ||
|
||||
typeof row.status !== "string"
|
||||
) {
|
||||
throw new Error("invalid ticket inbox row");
|
||||
}
|
||||
const prefix =
|
||||
row.kind === "cms"
|
||||
? "/ase/people/support/tickets"
|
||||
: "/ase/people/support/help-tickets";
|
||||
const dateValue =
|
||||
row.dateValue === null
|
||||
? null
|
||||
: row.dateValue instanceof Date
|
||||
? row.dateValue
|
||||
: new Date(row.dateValue);
|
||||
if (dateValue !== null && !Number.isFinite(dateValue.getTime())) {
|
||||
throw new Error("invalid ticket inbox date");
|
||||
}
|
||||
return {
|
||||
key: `${row.kind}-${id}`,
|
||||
kind: row.kind,
|
||||
id: String(id),
|
||||
title: row.title,
|
||||
user: row.user,
|
||||
userId: row.userId === null ? null : Number(row.userId),
|
||||
status: row.status,
|
||||
open: Boolean(row.open),
|
||||
sortAt,
|
||||
date: formatDate(dateValue, "date"),
|
||||
href: `${prefix}/${id}`,
|
||||
};
|
||||
});
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
||||
cmsTotal,
|
||||
helpTotal,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merged read-model over CMS desk + help-center queues (no DB merge). */
|
||||
export async function fetchUnifiedTicketInbox(
|
||||
options: FetchTicketInboxOptions,
|
||||
@@ -486,10 +279,10 @@ export async function fetchUnifiedTicketInbox(
|
||||
cmsTotal: number;
|
||||
helpTotal: number;
|
||||
}> {
|
||||
if (options.strict) return fetchStrictUnifiedTicketInbox(options);
|
||||
const type = options.type ?? "all";
|
||||
const openOnly = options.openOnly !== false;
|
||||
const search = options.search?.trim() ?? "";
|
||||
const base = options.base ?? "admin";
|
||||
|
||||
const includeCms = type === "all" || type === "cms";
|
||||
const includeHelp = type === "all" || type === "help";
|
||||
@@ -505,10 +298,10 @@ export async function fetchUnifiedTicketInbox(
|
||||
|
||||
const [cmsRows, helpRows] = await Promise.all([
|
||||
includeCms
|
||||
? fetchCmsCandidates(window, search, openOnly)
|
||||
? fetchCmsCandidates(window, search, openOnly, base)
|
||||
: Promise.resolve([]),
|
||||
includeHelp
|
||||
? fetchHelpCandidates(window, search, openOnly)
|
||||
? fetchHelpCandidates(window, search, openOnly, base)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -4,33 +4,23 @@ import { count, eq, ne } from "drizzle-orm";
|
||||
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
|
||||
|
||||
/** Open-queue sizes for dual ticket products (CMS desk vs help-center). */
|
||||
export async function fetchTicketQueueOpenCounts(options?: {
|
||||
readonly strict?: boolean;
|
||||
}): Promise<{
|
||||
export async function fetchTicketQueueOpenCounts(): Promise<{
|
||||
cmsOpen: number;
|
||||
helpOpen: number;
|
||||
}> {
|
||||
const cms = db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(ne(WebsiteTicket.status, "closed"));
|
||||
const help = db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.open, true));
|
||||
const readCount = async (
|
||||
query: typeof cms | typeof help,
|
||||
): Promise<number> => {
|
||||
const rows = await query;
|
||||
const value = Number(rows[0]?.total);
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error("invalid ticket queue count");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const [cmsOpen, helpOpen] = await Promise.all([
|
||||
options?.strict ? readCount(cms) : readCount(cms).catch(() => 0),
|
||||
options?.strict ? readCount(help) : readCount(help).catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(ne(WebsiteTicket.status, "closed"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.open, true))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
]);
|
||||
return { cmsOpen, helpOpen };
|
||||
}
|
||||
+8
-14
@@ -33,20 +33,14 @@ describe("cached (memory-only, no Redis)", () => {
|
||||
});
|
||||
|
||||
it("recomputes after the TTL expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(1_000);
|
||||
let count = 0;
|
||||
const fn = vi.fn(async () => ++count);
|
||||
const key = `ttl-${Math.random()}`;
|
||||
expect(await cached(key, 100, fn)).toBe(1);
|
||||
expect(await cached(key, 100, fn)).toBe(1);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(await cached(key, 100, fn)).toBe(2);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
let count = 0;
|
||||
const fn = vi.fn(async () => ++count);
|
||||
const key = `ttl-${Math.random()}`;
|
||||
expect(await cached(key, 1, fn)).toBe(1);
|
||||
expect(await cached(key, 1, fn)).toBe(1);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(await cached(key, 1, fn)).toBe(2);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("invalidates a key so the next read recomputes", async () => {
|
||||
|
||||
@@ -75,7 +75,7 @@ const SAFE_REDIRECT_PATHS = new Set([
|
||||
function isSafePath(path: string): boolean {
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (SAFE_REDIRECT_PATHS.has(path)) return true;
|
||||
if (path.startsWith("/ase/") || path.startsWith("/api/")) return true;
|
||||
if (path.startsWith("/admin/") || path.startsWith("/api/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createHousekeepingPreferencesRepository,
|
||||
type HousekeepingPreferencesStorage,
|
||||
} from "@/features/housekeeping/foundation/preferences/repository";
|
||||
import { type Db, db, HousekeepingUserPreferences } from "@/lib/db";
|
||||
|
||||
type HousekeepingPreferencesDb = Pick<Db, "select" | "insert">;
|
||||
|
||||
export function createDrizzleHousekeepingPreferencesStorage(
|
||||
database: HousekeepingPreferencesDb,
|
||||
): HousekeepingPreferencesStorage {
|
||||
return {
|
||||
async findByUserId(userId) {
|
||||
const rows = await database
|
||||
.select({
|
||||
schemaVersion: HousekeepingUserPreferences.schemaVersion,
|
||||
payload: HousekeepingUserPreferences.payload,
|
||||
})
|
||||
.from(HousekeepingUserPreferences)
|
||||
.where(eq(HousekeepingUserPreferences.userId, userId))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
async upsert(row) {
|
||||
await database
|
||||
.insert(HousekeepingUserPreferences)
|
||||
.values(row)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
schemaVersion: row.schemaVersion,
|
||||
payload: row.payload,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const housekeepingPreferencesRepository =
|
||||
createHousekeepingPreferencesRepository(
|
||||
createDrizzleHousekeepingPreferencesStorage(db),
|
||||
);
|
||||
@@ -1,45 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingResult,
|
||||
} from "@/features/housekeeping/foundation/contracts";
|
||||
import type { HousekeepingRecentItem } from "@/features/housekeeping/foundation/recent/recent-work";
|
||||
import { createHousekeepingRecentWorkService } from "@/features/housekeeping/foundation/recent/recent-work";
|
||||
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||
import { AdminAuditLog, db } from "@/lib/db";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
|
||||
const recentWorkService = createHousekeepingRecentWorkService({
|
||||
registry: createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
|
||||
async loadRows(userId, limit) {
|
||||
return db
|
||||
.select({
|
||||
action: AdminAuditLog.action,
|
||||
target: AdminAuditLog.target,
|
||||
domain: AdminAuditLog.domain,
|
||||
outcome: AdminAuditLog.outcome,
|
||||
createdAt: AdminAuditLog.createdAt,
|
||||
})
|
||||
.from(AdminAuditLog)
|
||||
.where(eq(AdminAuditLog.userId, userId))
|
||||
.orderBy(desc(AdminAuditLog.id))
|
||||
.limit(limit);
|
||||
},
|
||||
writeAudit: logAudit,
|
||||
});
|
||||
|
||||
export async function loadHousekeepingRecentWork(
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingResult<readonly HousekeepingRecentItem[]>> {
|
||||
return recentWorkService.load(context);
|
||||
}
|
||||
|
||||
export async function recordHousekeepingRouteVisit(
|
||||
routeId: string,
|
||||
context: HousekeepingCapabilityContext,
|
||||
): Promise<HousekeepingResult<HousekeepingRecentItem>> {
|
||||
return recentWorkService.recordVisit(routeId, context);
|
||||
}
|
||||
@@ -35,6 +35,11 @@ describe("admin import backend contract", () => {
|
||||
expect(source, path).toContain("PERMS.ASSETS_IMPORT");
|
||||
});
|
||||
|
||||
it("does not leave import actions as successful no-op stubs", () => {
|
||||
const source = readFileSync("src/actions/import-furni.ts", "utf8");
|
||||
expect(source).not.toContain("deleted: 0, remaining: 0");
|
||||
});
|
||||
|
||||
it("keeps badge import ExternalTexts + WebsiteBadges in sync", () => {
|
||||
const service = readFileSync("src/lib/services/import-badge.ts", "utf8");
|
||||
expect(service).toContain("ExternalTexts.json");
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldRedirectHousekeepingRequest } from "./proxy-access";
|
||||
import { shouldRedirectAdminRequest } from "./proxy-access";
|
||||
|
||||
describe("shouldRedirectHousekeepingRequest", () => {
|
||||
it("redirects anonymous Housekeeping requests before rendering", () => {
|
||||
expect(shouldRedirectHousekeepingRequest("/ase", null)).toBe(true);
|
||||
describe("shouldRedirectAdminRequest", () => {
|
||||
it("redirects anonymous admin requests before rendering", () => {
|
||||
expect(shouldRedirectAdminRequest("/admin", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("defers every authenticated rank to database authorization", () => {
|
||||
expect(
|
||||
shouldRedirectHousekeepingRequest("/ase/system/access/permissions", {
|
||||
rank: 1,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRedirectHousekeepingRequest("/ase/system/access/permissions", {
|
||||
rank: 2000,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(shouldRedirectHousekeepingRequest("/api/admin/csrf", null)).toBe(
|
||||
expect(shouldRedirectAdminRequest("/admin/permissions", { rank: 1 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldRedirectHousekeepingRequest("/news", null)).toBe(false);
|
||||
expect(
|
||||
shouldRedirectAdminRequest("/admin/permissions", { rank: 2000 }),
|
||||
).toBe(false);
|
||||
expect(shouldRedirectAdminRequest("/news", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,10 @@ export interface ProxyToken {
|
||||
rank?: unknown;
|
||||
}
|
||||
|
||||
export function shouldRedirectHousekeepingRequest(
|
||||
export function shouldRedirectAdminRequest(
|
||||
pathname: string,
|
||||
token: ProxyToken | null,
|
||||
): boolean {
|
||||
if (pathname !== "/ase" && !pathname.startsWith("/ase/")) return false;
|
||||
if (pathname !== "/admin" && !pathname.startsWith("/admin/")) return false;
|
||||
return token === null;
|
||||
}
|
||||
@@ -156,7 +156,7 @@ async function emailStaff(input: SendAlertInput): Promise<boolean> {
|
||||
? `<table style="border-collapse:collapse;font-size:13px">${contextRows}</table>`
|
||||
: "") +
|
||||
`<p style="margin-top:16px;color:#888;font-size:12px">` +
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/ase/system/operations/alerts">view alerts</a></p>`;
|
||||
`Sent by ${escapeHtml(env.HOTEL_NAME)} · <a href="${env.APP_URL}/admin/alerts">view alerts</a></p>`;
|
||||
|
||||
try {
|
||||
return await sendMail(to, subject, html);
|
||||
|
||||
@@ -87,61 +87,6 @@ describe("logAudit", () => {
|
||||
expect(JSON.parse(data.diff).username).toEqual({ from: "foo", to: "bar" });
|
||||
});
|
||||
|
||||
it("redacts sensitive keys recursively in nested objects and arrays", async () => {
|
||||
insertValues.mockResolvedValue({ id: 1 });
|
||||
await logAudit({
|
||||
userId: 1,
|
||||
action: "update",
|
||||
target: "user",
|
||||
before: {
|
||||
profile: {
|
||||
authTicket: "private-ticket",
|
||||
credentials: { newPassword: "one-time-password" },
|
||||
},
|
||||
integrations: [{ api_key: "private-key" }],
|
||||
},
|
||||
});
|
||||
|
||||
const before = JSON.parse(insertValues.mock.calls[0][0].before);
|
||||
expect(before.profile.authTicket).toBe("[Redacted]");
|
||||
expect(before.profile.credentials.newPassword).toBe("[Redacted]");
|
||||
expect(before.integrations[0].api_key).toBe("[Redacted]");
|
||||
});
|
||||
|
||||
it("persists correlation, domain, outcome, reason, and IP evidence", async () => {
|
||||
insertValues.mockResolvedValue({ id: 1 });
|
||||
await logAudit({
|
||||
userId: 1,
|
||||
action: "ban",
|
||||
target: "user",
|
||||
correlationId: "corr-123",
|
||||
domain: "people",
|
||||
outcome: "denied",
|
||||
reason: "Policy requirement was not met",
|
||||
ipAddress: "127.0.0.1",
|
||||
});
|
||||
|
||||
expect(insertValues.mock.calls[0][0]).toMatchObject({
|
||||
correlationId: "corr-123",
|
||||
domain: "people",
|
||||
outcome: "denied",
|
||||
reason: "Policy requirement was not met",
|
||||
ipAddress: "127.0.0.1",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes through the injected transaction when one is supplied", async () => {
|
||||
const transactionValues = vi.fn().mockResolvedValue({ id: 1 });
|
||||
const transactionInsert = vi.fn(() => ({ values: transactionValues }));
|
||||
|
||||
await logAudit({ userId: 1, action: "update", target: "settings" }, {
|
||||
insert: transactionInsert,
|
||||
} as never);
|
||||
|
||||
expect(transactionInsert).toHaveBeenCalledOnce();
|
||||
expect(transactionValues).toHaveBeenCalledOnce();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
it("omits diff when only before or after is missing", async () => {
|
||||
insertValues.mockResolvedValue({ id: 1 });
|
||||
await logAudit({
|
||||
@@ -241,21 +186,4 @@ describe("getAuditLogs", () => {
|
||||
const result = await getAuditLogs();
|
||||
expect(result.rows[0].username).toBe("User #99");
|
||||
});
|
||||
it("fails closed beyond the redaction depth cap without mutating the input", async () => {
|
||||
const sentinel = "raw-depth-secret";
|
||||
const deep = {
|
||||
a: { b: { c: { d: { e: { f: { g: { secret: sentinel } } } } } } },
|
||||
};
|
||||
insertValues.mockResolvedValue({ id: 1 });
|
||||
await logAudit({
|
||||
userId: 1,
|
||||
action: "update",
|
||||
target: "user",
|
||||
before: { deep, state: "before" },
|
||||
after: { deep, state: "after" },
|
||||
});
|
||||
const data = insertValues.mock.calls[0][0];
|
||||
expect(`${data.before}${data.after}${data.diff}`).not.toContain(sentinel);
|
||||
expect(deep.a.b.c.d.e.f.g.secret).toBe(sentinel);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,13 @@
|
||||
import { count, desc, inArray, like, or } from "drizzle-orm";
|
||||
import type { HousekeepingDomainId } from "@/features/housekeeping/migration/types";
|
||||
import { AdminAuditLog, type Db, db, User } from "@/lib/db";
|
||||
import { AdminAuditLog, db, User } from "@/lib/db";
|
||||
|
||||
export interface AuditEntry {
|
||||
interface AuditEntry {
|
||||
userId: number;
|
||||
action: string;
|
||||
target: string;
|
||||
targetId?: number;
|
||||
before?: Record<string, unknown>;
|
||||
after?: Record<string, unknown>;
|
||||
correlationId?: string;
|
||||
outcome?: "intent" | "success" | "failure" | "partial" | "denied";
|
||||
reason?: string;
|
||||
domain?: HousekeepingDomainId;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export type HousekeepingAuditTransaction = Pick<Db, "insert">;
|
||||
|
||||
export interface HousekeepingAuditWriter {
|
||||
write(
|
||||
entry: AuditEntry,
|
||||
transaction?: HousekeepingAuditTransaction,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
const SENSITIVE_KEY_RE =
|
||||
@@ -30,8 +15,7 @@ const SENSITIVE_KEY_RE =
|
||||
const REDACTED = "[Redacted]";
|
||||
|
||||
function sanitizeAuditPayload(value: unknown, depth = 0): unknown {
|
||||
if (depth > 6) return REDACTED;
|
||||
if (value == null) return value;
|
||||
if (depth > 6 || value == null) return value;
|
||||
if (Array.isArray(value))
|
||||
return value.map((v) => sanitizeAuditPayload(v, depth + 1));
|
||||
if (typeof value !== "object") return value;
|
||||
@@ -63,10 +47,7 @@ function computeDiff(
|
||||
return Object.keys(diff).length > 0 ? diff : null;
|
||||
}
|
||||
|
||||
export async function logAudit(
|
||||
entry: AuditEntry,
|
||||
transaction?: HousekeepingAuditTransaction,
|
||||
): Promise<void> {
|
||||
export async function logAudit(entry: AuditEntry): Promise<void> {
|
||||
const sanitizedBefore = entry.before
|
||||
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
|
||||
: undefined;
|
||||
@@ -75,8 +56,7 @@ export async function logAudit(
|
||||
: undefined;
|
||||
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
|
||||
|
||||
const auditWriter: HousekeepingAuditTransaction = transaction ?? db;
|
||||
await auditWriter.insert(AdminAuditLog).values({
|
||||
await db.insert(AdminAuditLog).values({
|
||||
userId: entry.userId,
|
||||
action: entry.action,
|
||||
target: entry.target,
|
||||
@@ -84,19 +64,10 @@ export async function logAudit(
|
||||
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
|
||||
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
|
||||
diff: diff ? JSON.stringify(diff) : null,
|
||||
correlationId: entry.correlationId,
|
||||
outcome: entry.outcome,
|
||||
reason: entry.reason,
|
||||
domain: entry.domain,
|
||||
ipAddress: entry.ipAddress,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export const housekeepingAuditWriter: HousekeepingAuditWriter = {
|
||||
write: logAudit,
|
||||
};
|
||||
|
||||
interface GetLogsOptions {
|
||||
search?: string;
|
||||
page?: number;
|
||||
|
||||
@@ -134,16 +134,13 @@ async function fetchWithFallback(url: string): Promise<string> {
|
||||
export async function fetchSourceFurnidata(
|
||||
url: string,
|
||||
now = Date.now(),
|
||||
signal?: AbortSignal,
|
||||
): Promise<SourceFurni[]> {
|
||||
signal?.throwIfAborted();
|
||||
const hit = cache.get(url);
|
||||
if (hit && now && now - hit.ts < TTL) return hit.list;
|
||||
let body: string;
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(30000);
|
||||
const res = await fetch(url, {
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
headers: browserHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -162,8 +159,7 @@ export async function fetchSourceFurnidata(
|
||||
} else {
|
||||
body = await fetchWithFallback(url);
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
} catch {
|
||||
body = await fetchWithFallback(url);
|
||||
}
|
||||
if (body.trimStart().startsWith("<")) {
|
||||
@@ -247,13 +243,11 @@ export async function cloneSingleFurni(params: {
|
||||
source: CloneSource;
|
||||
entry: SourceFurni;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Skip the per-item FurnitureData.json write and return the entry instead,
|
||||
* so batch callers can append everything in one lock + write cycle. */
|
||||
deferFurniData?: boolean;
|
||||
}): Promise<CloneResult> {
|
||||
const { source, entry, onProgress, signal, deferFurniData } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { source, entry, onProgress, deferFurniData } = params;
|
||||
const { classname, itemType } = entry;
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -293,7 +287,6 @@ export async function cloneSingleFurni(params: {
|
||||
|
||||
// Download .nitro + icon directly from the source hotel.
|
||||
onProgress?.("downloading");
|
||||
signal?.throwIfAborted();
|
||||
const nitroPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${classname}.nitro`,
|
||||
@@ -307,7 +300,6 @@ export async function cloneSingleFurni(params: {
|
||||
nitroPath,
|
||||
{
|
||||
maxRetries: 2,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!dl.ok) {
|
||||
@@ -342,7 +334,6 @@ export async function cloneSingleFurni(params: {
|
||||
{
|
||||
maxRetries: 1,
|
||||
validate: "png",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!iconDl.ok) {
|
||||
@@ -375,7 +366,6 @@ export async function cloneSingleFurni(params: {
|
||||
}
|
||||
|
||||
onProgress?.("writing_db");
|
||||
signal?.throwIfAborted();
|
||||
const stackHeight =
|
||||
entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
|
||||
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
|
||||
@@ -432,7 +422,6 @@ export async function cloneSingleFurni(params: {
|
||||
// AND our offerid. The source's offerid points at the source hotel's sprite
|
||||
// ids, which would silently break the emulator's catalog offer lookup.
|
||||
onProgress?.("writing_furnidata");
|
||||
signal?.throwIfAborted();
|
||||
const furniDataEntry = {
|
||||
...entry,
|
||||
id: newId,
|
||||
|
||||
@@ -89,10 +89,8 @@ export async function importClothingSet(params: {
|
||||
setType: string;
|
||||
setId: number;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportClothingSetResult> {
|
||||
const { setType, setId, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { setType, setId, onProgress } = params;
|
||||
const warnings: string[] = [];
|
||||
|
||||
const [officialFd, officialLibs] = await Promise.all([
|
||||
@@ -138,17 +136,12 @@ export async function importClothingSet(params: {
|
||||
let hardFail = false;
|
||||
|
||||
for (const libId of requiredLibIds) {
|
||||
signal?.throwIfAborted();
|
||||
const officialLib = officialLibs.find((l) => l.id === libId);
|
||||
if (!officialLib) continue;
|
||||
|
||||
if (!localLibIds.has(libId)) {
|
||||
onProgress?.(`importing library ${libId}`);
|
||||
const result = await importSingleFigure({
|
||||
lib: libId,
|
||||
onProgress,
|
||||
signal,
|
||||
});
|
||||
const result = await importSingleFigure({ lib: libId, onProgress });
|
||||
if (!result.ok) {
|
||||
hardFail = true;
|
||||
warnings.push(
|
||||
@@ -176,7 +169,6 @@ export async function importClothingSet(params: {
|
||||
}
|
||||
|
||||
const mappedSet = mapOfficialSet(set);
|
||||
signal?.throwIfAborted();
|
||||
await mergeSet(setType, st.paletteId, mappedSet as never);
|
||||
|
||||
if (palette) {
|
||||
|
||||
@@ -76,10 +76,8 @@ export async function importSingleEffect(params: {
|
||||
type: string;
|
||||
revision: number;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportEffectResult> {
|
||||
const { id, lib, type, revision, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { id, lib, type, revision, onProgress } = params;
|
||||
const warnings: string[] = [];
|
||||
await ensureEffectDirs();
|
||||
|
||||
@@ -102,10 +100,7 @@ export async function importSingleEffect(params: {
|
||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
const dl = await downloadFile(swfUrl, swfPath, {
|
||||
validate: "swf",
|
||||
signal,
|
||||
});
|
||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
||||
if (!dl.ok)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -116,7 +111,6 @@ export async function importSingleEffect(params: {
|
||||
|
||||
// Convert SWF → Nitro
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
const result = convertSwfToNitro(swfBuffer, lib);
|
||||
@@ -133,7 +127,6 @@ export async function importSingleEffect(params: {
|
||||
|
||||
// Merge EffectMap.json (last step — no orphan entry if anything above failed)
|
||||
onProgress?.("writing_map");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const entry: EffectMapEntry = { id, lib, type, revision };
|
||||
await mergeEffect(entry);
|
||||
|
||||
@@ -78,10 +78,8 @@ async function nitroPathFor(lib: string): Promise<string> {
|
||||
export async function importSingleFigure(params: {
|
||||
lib: string;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportFigureResult> {
|
||||
const { lib, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { lib, onProgress } = params;
|
||||
const warnings: string[] = [];
|
||||
await ensureFigureDirs();
|
||||
|
||||
@@ -101,10 +99,7 @@ export async function importSingleFigure(params: {
|
||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
const dl = await downloadFile(swfUrl, swfPath, {
|
||||
validate: "swf",
|
||||
signal,
|
||||
});
|
||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
||||
if (!dl.ok)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -114,7 +109,6 @@ export async function importSingleFigure(params: {
|
||||
};
|
||||
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
const result = convertSwfToNitro(swfBuffer, lib);
|
||||
|
||||
@@ -403,13 +403,10 @@ async function syncDirMissingFiles(
|
||||
* so all imported furniture stays renderable in-game even when a mirror
|
||||
* write was skipped.
|
||||
*/
|
||||
export async function syncAssetsToGamedataBundle(
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
copiedNitros: string[];
|
||||
copiedIcons: string[];
|
||||
}> {
|
||||
signal?.throwIfAborted();
|
||||
if (process.platform === "win32")
|
||||
return { copiedNitros: [], copiedIcons: [] };
|
||||
|
||||
@@ -426,7 +423,6 @@ export async function syncAssetsToGamedataBundle(
|
||||
"bundled/furniture",
|
||||
);
|
||||
if (nitroDir !== nitroTarget) {
|
||||
signal?.throwIfAborted();
|
||||
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
|
||||
entry.endsWith(".nitro"),
|
||||
);
|
||||
@@ -438,7 +434,6 @@ export async function syncAssetsToGamedataBundle(
|
||||
"icons",
|
||||
);
|
||||
if (iconDir !== iconTarget) {
|
||||
signal?.throwIfAborted();
|
||||
const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) =>
|
||||
entry.endsWith("_icon.png"),
|
||||
);
|
||||
@@ -513,7 +508,6 @@ export async function importSingleFurni(params: {
|
||||
skipFurniDataWrite?: boolean;
|
||||
updateExisting?: boolean;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
||||
sourceSwfBaseUrl?: string;
|
||||
/** Per-source nitro bundle + icon base URL. */
|
||||
@@ -531,12 +525,10 @@ export async function importSingleFurni(params: {
|
||||
skipFurniDataWrite,
|
||||
updateExisting,
|
||||
onProgress,
|
||||
signal,
|
||||
sourceSwfBaseUrl,
|
||||
nitroBaseUrl: sourceNitroBaseUrl,
|
||||
iconBaseUrl: sourceIconBaseUrl,
|
||||
} = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check if already exists by classname OR by spriteId (primary key collision).
|
||||
@@ -612,7 +604,6 @@ export async function importSingleFurni(params: {
|
||||
|
||||
// ── Insert into DB with enriched data ──────────────────────────────
|
||||
onProgress?.("writing_db");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
if (isUpdate) {
|
||||
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
|
||||
@@ -669,7 +660,6 @@ export async function importSingleFurni(params: {
|
||||
// fall back to base-classname icon (Nitro reuses base for all colors
|
||||
// when no color-specific icon exists).
|
||||
onProgress?.("downloading");
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const iconColorAware = classname.replace(/\*/g, "_");
|
||||
|
||||
@@ -711,8 +701,8 @@ export async function importSingleFurni(params: {
|
||||
// Download icon + SWF concurrently (independent assets). Each tries its
|
||||
// candidate URLs in order until one succeeds.
|
||||
const [iconOkResult, swfOk] = await Promise.all([
|
||||
tryDownloadCandidates(iconUrls, iconPath, "png", signal),
|
||||
tryDownloadCandidates(swfUrls, swfPath, "swf", signal),
|
||||
tryDownloadCandidates(iconUrls, iconPath, "png"),
|
||||
tryDownloadCandidates(swfUrls, swfPath, "swf"),
|
||||
]);
|
||||
let iconOk = iconOkResult;
|
||||
|
||||
@@ -725,10 +715,7 @@ export async function importSingleFurni(params: {
|
||||
!existsSync(/*turbopackIgnore: true*/ nitroPath)
|
||||
) {
|
||||
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
|
||||
const dl = await downloadFile(nitroUrl, nitroPath, {
|
||||
validate: "png",
|
||||
signal,
|
||||
});
|
||||
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" });
|
||||
if (dl.ok) nitroDownloadOk = true;
|
||||
}
|
||||
|
||||
@@ -757,7 +744,6 @@ export async function importSingleFurni(params: {
|
||||
|
||||
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
let conversionResult: ConversionResult | null = null;
|
||||
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
|
||||
|
||||
@@ -635,21 +635,14 @@ export interface FixAllResult {
|
||||
|
||||
/** Run every repair in a sensible order, then return before/after health. */
|
||||
export async function fixEverything(
|
||||
opts: { dedupePages?: boolean; signal?: AbortSignal } = {},
|
||||
opts: { dedupePages?: boolean } = {},
|
||||
): Promise<FixAllResult> {
|
||||
opts.signal?.throwIfAborted();
|
||||
const healthBefore = await getFurniHealth();
|
||||
opts.signal?.throwIfAborted();
|
||||
const sprite = await fixSpriteIds();
|
||||
opts.signal?.throwIfAborted();
|
||||
const offers = await fixCatalogOffers();
|
||||
opts.signal?.throwIfAborted();
|
||||
const reconcile = await reconcileIds();
|
||||
opts.signal?.throwIfAborted();
|
||||
const dedup = await removeDuplicates(opts.dedupePages ?? true);
|
||||
opts.signal?.throwIfAborted();
|
||||
const align = await forceItemsBaseIdsToFurnidata(true);
|
||||
opts.signal?.throwIfAborted();
|
||||
const healthAfter = await getFurniHealth();
|
||||
return { healthBefore, sprite, offers, reconcile, dedup, align, healthAfter };
|
||||
}
|
||||
@@ -17,9 +17,7 @@ export async function writeBadgeToExternalTexts(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImportBadgeResult> {
|
||||
signal?.throwIfAborted();
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (!gamedataRoot) {
|
||||
return {
|
||||
@@ -37,7 +35,6 @@ export async function writeBadgeToExternalTexts(
|
||||
const raw = await fs.readFile(extPath, "utf-8");
|
||||
texts = JSON.parse(raw) as Record<string, string>;
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
// File not present yet (fresh deployment) — start with an empty object.
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
console.warn(
|
||||
@@ -52,7 +49,6 @@ export async function writeBadgeToExternalTexts(
|
||||
}
|
||||
texts = {};
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (texts[nameKey] !== undefined) {
|
||||
return {
|
||||
@@ -67,11 +63,9 @@ export async function writeBadgeToExternalTexts(
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(extPath), { recursive: true });
|
||||
signal?.throwIfAborted();
|
||||
await fs.writeFile(extPath, JSON.stringify(texts, null, 4), "utf-8");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
console.warn(
|
||||
"[import-badges] Failed to update ExternalTexts.json:",
|
||||
(err as Error).message,
|
||||
@@ -89,9 +83,7 @@ export async function upsertWebsiteBadge(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(WebsiteBadges)
|
||||
@@ -115,28 +107,21 @@ export async function upsertWebsiteBadge(
|
||||
* Single import path: ExternalTexts.json + WebsiteBadges.
|
||||
* Callers should gate with ASSETS_IMPORT.
|
||||
*/
|
||||
export async function importBadgeSynced(
|
||||
input: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImportBadgeResult> {
|
||||
signal?.throwIfAborted();
|
||||
export async function importBadgeSynced(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<ImportBadgeResult> {
|
||||
const texts = await writeBadgeToExternalTexts(
|
||||
input.code,
|
||||
input.name,
|
||||
input.description,
|
||||
signal,
|
||||
);
|
||||
if (!texts.ok) return texts;
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
await upsertWebsiteBadge(input.code, input.name, input.description, signal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
await upsertWebsiteBadge(input.code, input.name, input.description);
|
||||
} catch {
|
||||
return { ok: false, error: "Failed to import badge", status: 500 };
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,9 @@ export async function tryDownloadCandidates(
|
||||
urls: string[],
|
||||
destPath: string,
|
||||
kind: "swf" | "png",
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
for (const url of urls) {
|
||||
signal?.throwIfAborted();
|
||||
const res = await downloadFile(url, destPath, { validate: kind, signal });
|
||||
const res = await downloadFile(url, destPath, { validate: kind });
|
||||
if (res.ok) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -35,26 +33,18 @@ export async function tryDownloadCandidates(
|
||||
export async function downloadFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
options?: {
|
||||
maxRetries?: number;
|
||||
validate?: "swf" | "png";
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
options?: { maxRetries?: number; validate?: "swf" | "png" },
|
||||
): Promise<{ ok: boolean; size: number }> {
|
||||
const maxRetries = options?.maxRetries ?? 3;
|
||||
const baseDelay = 1000;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
options?.signal?.throwIfAborted();
|
||||
if (attempt > 0) {
|
||||
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
||||
}
|
||||
const timeout = AbortSignal.timeout(15000);
|
||||
const res = await fetch(url, {
|
||||
signal: options?.signal
|
||||
? AbortSignal.any([options.signal, timeout])
|
||||
: timeout,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers: browserHeaders("image", {
|
||||
Accept: "image/png,image/*,*/*;q=0.8",
|
||||
}),
|
||||
@@ -92,7 +82,6 @@ export async function downloadFile(
|
||||
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
|
||||
return { ok: true, size: buffer.length };
|
||||
} catch (err) {
|
||||
if (options?.signal?.aborted) throw err;
|
||||
if (attempt === maxRetries) {
|
||||
console.warn(
|
||||
`[import-download] Download error ${url}:`,
|
||||
|
||||
@@ -21,53 +21,6 @@ export interface ModerationResult {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type ModerationAction =
|
||||
| { readonly action: "kick"; readonly userId: number }
|
||||
| {
|
||||
readonly action: "mute";
|
||||
readonly userId: number;
|
||||
readonly duration: number;
|
||||
}
|
||||
| { readonly action: "unmute"; readonly userId: number }
|
||||
| {
|
||||
readonly action: "alert";
|
||||
readonly userId: number;
|
||||
readonly message: string;
|
||||
}
|
||||
| { readonly action: "room-kick"; readonly roomId: number }
|
||||
| {
|
||||
readonly action: "broadcast";
|
||||
readonly message: string;
|
||||
readonly type: "hotel" | "staff";
|
||||
};
|
||||
|
||||
export interface ModerationActionTransport {
|
||||
disconnectUser(userId: number): Promise<boolean>;
|
||||
muteUser(userId: number, duration: number): Promise<boolean>;
|
||||
unmuteUser(userId: number): Promise<boolean>;
|
||||
alertUser(userId: number, message: string): Promise<boolean>;
|
||||
kickAll(roomId: number): Promise<boolean>;
|
||||
hotelAlert(message: string): Promise<boolean>;
|
||||
staffAlert(message: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
// Execute one already-authorized moderation effect and expose delivery truth.
|
||||
export async function executeModerationAction(
|
||||
transport: ModerationActionTransport,
|
||||
input: ModerationAction,
|
||||
): Promise<boolean> {
|
||||
if (input.action === "kick") return transport.disconnectUser(input.userId);
|
||||
if (input.action === "mute")
|
||||
return transport.muteUser(input.userId, input.duration);
|
||||
if (input.action === "unmute") return transport.unmuteUser(input.userId);
|
||||
if (input.action === "alert")
|
||||
return transport.alertUser(input.userId, input.message);
|
||||
if (input.action === "room-kick") return transport.kickAll(input.roomId);
|
||||
return input.type === "hotel"
|
||||
? transport.hotelAlert(input.message)
|
||||
: transport.staffAlert(input.message);
|
||||
}
|
||||
|
||||
const OPENAI_MODERATIONS_URL = "https://api.openai.com/v1/moderations";
|
||||
// Bound the AI call so a slow/hung endpoint can't stall a server action.
|
||||
const OPENAI_TIMEOUT_MS = 5_000;
|
||||
|
||||
@@ -45,10 +45,8 @@ function nitroPathFor(lib: string): string {
|
||||
export async function importSinglePet(params: {
|
||||
lib: string;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportPetResult> {
|
||||
const { lib, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { lib, onProgress } = params;
|
||||
const warnings: string[] = [];
|
||||
await ensurePetDirs();
|
||||
|
||||
@@ -68,10 +66,7 @@ export async function importSinglePet(params: {
|
||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
const dl = await downloadFile(swfUrl, swfPath, {
|
||||
validate: "swf",
|
||||
signal,
|
||||
});
|
||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
||||
if (!dl.ok)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -81,7 +76,6 @@ export async function importSinglePet(params: {
|
||||
};
|
||||
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
const result = convertSwfToNitro(swfBuffer, lib);
|
||||
|
||||
@@ -104,9 +104,7 @@ export interface RepairEvent {
|
||||
|
||||
export async function repairMissingIcons(
|
||||
onEvent?: (evt: RepairEvent) => void,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
||||
options?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -206,7 +204,6 @@ export async function repairMissingIcons(
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
const processItem = async (classname: string) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
||||
// .png icon files to repair. Skip them.
|
||||
if (dbSkipSet.has(classname)) {
|
||||
@@ -315,7 +312,7 @@ export async function repairMissingIcons(
|
||||
targets.iconDir,
|
||||
fileName,
|
||||
),
|
||||
{ maxRetries: 1, validate: "png", signal: options?.signal },
|
||||
{ maxRetries: 1, validate: "png" },
|
||||
);
|
||||
if (dl.ok) {
|
||||
const icon = await fs.readFile(
|
||||
@@ -353,7 +350,7 @@ export async function repairMissingIcons(
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${classname}.nitro`,
|
||||
nitroTmp,
|
||||
{ maxRetries: 1, signal: options?.signal },
|
||||
{ maxRetries: 1 },
|
||||
);
|
||||
if (dl.ok) {
|
||||
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
||||
@@ -393,7 +390,6 @@ export async function repairMissingIcons(
|
||||
};
|
||||
|
||||
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
const classname = item.item_name;
|
||||
const result = await processItem(classname);
|
||||
if (result.status === "skipped") {
|
||||
|
||||
@@ -106,9 +106,7 @@ export interface RepairNitroEvent {
|
||||
|
||||
export async function repairMissingNitros(
|
||||
onEvent?: (evt: RepairNitroEvent) => void,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
||||
options?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -203,7 +201,6 @@ export async function repairMissingNitros(
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
const processItem = async (classname: string) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
||||
// furni .nitro bundles to repair. Skip them.
|
||||
if (skipSet.has(classname)) {
|
||||
@@ -279,7 +276,7 @@ export async function repairMissingNitros(
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${nitroName}`,
|
||||
tmpPath,
|
||||
{ maxRetries: 1, signal: options?.signal },
|
||||
{ maxRetries: 1 },
|
||||
);
|
||||
if (dl.ok) {
|
||||
// Validate it is a real Nitro bundle before writing it
|
||||
@@ -318,7 +315,6 @@ export async function repairMissingNitros(
|
||||
};
|
||||
|
||||
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
const classname = item.item_name;
|
||||
const result = await processItem(classname);
|
||||
if (result.status === "skipped") {
|
||||
@@ -388,14 +384,13 @@ export async function repairMissingNitros(
|
||||
*/
|
||||
export async function addSize32AllNitros(
|
||||
onEvent?: (evt: RepairNitroEvent) => void,
|
||||
opts?: { concurrency?: number; signal?: AbortSignal },
|
||||
opts?: { concurrency?: number },
|
||||
): Promise<{
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
existing: number;
|
||||
}> {
|
||||
opts?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -447,7 +442,6 @@ export async function addSize32AllNitros(
|
||||
[...fileNames],
|
||||
opts?.concurrency ?? 12,
|
||||
async (fileName, i) => {
|
||||
opts?.signal?.throwIfAborted();
|
||||
let buf: Buffer | null = null;
|
||||
for (const dir of nitroDirs) {
|
||||
try {
|
||||
|
||||
@@ -19,56 +19,6 @@ export interface TicketReplyDb {
|
||||
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
|
||||
}
|
||||
|
||||
export const MAX_UNSIGNED_TICKET_ID = 18_446_744_073_709_551_615n;
|
||||
|
||||
function boundedDecimalPattern(maximum: string): RegExp {
|
||||
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
|
||||
for (let index = 0; index < maximum.length; index += 1) {
|
||||
const maximumDigit = Number(maximum[index]);
|
||||
const minimumDigit = index === 0 ? 1 : 0;
|
||||
const upperDigit = maximumDigit - 1;
|
||||
if (upperDigit < minimumDigit) continue;
|
||||
const digit =
|
||||
upperDigit === minimumDigit
|
||||
? String(minimumDigit)
|
||||
: `[${minimumDigit}-${upperDigit}]`;
|
||||
alternatives.push(
|
||||
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
|
||||
);
|
||||
}
|
||||
alternatives.push(maximum);
|
||||
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
|
||||
}
|
||||
|
||||
export const CANONICAL_TICKET_ID_PATTERN = boundedDecimalPattern(
|
||||
MAX_UNSIGNED_TICKET_ID.toString(),
|
||||
);
|
||||
|
||||
// Canonicalize a SQL BIGINT identifier without passing through Number.
|
||||
export function canonicalTicketId(value: string | number | bigint): bigint {
|
||||
let parsed: bigint;
|
||||
try {
|
||||
if (typeof value === "bigint") {
|
||||
parsed = value;
|
||||
} else if (typeof value === "string") {
|
||||
if (!CANONICAL_TICKET_ID_PATTERN.test(value)) {
|
||||
throw new Error("noncanonical identifier");
|
||||
}
|
||||
parsed = BigInt(value);
|
||||
} else if (Number.isSafeInteger(value) && value > 0) {
|
||||
parsed = BigInt(value);
|
||||
} else {
|
||||
throw new Error("unsafe identifier");
|
||||
}
|
||||
} catch {
|
||||
throw new Error("invalid ticket identifier");
|
||||
}
|
||||
if (parsed <= 0n || parsed > MAX_UNSIGNED_TICKET_ID) {
|
||||
throw new Error("invalid ticket identifier");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function createOwnedTicketReply(
|
||||
db: TicketReplyDb,
|
||||
input: { ticketId: bigint; userId: number; content: string },
|
||||
|
||||
@@ -234,7 +234,6 @@ export async function uploadSingleFurni(params: {
|
||||
nitroBuffer: Buffer;
|
||||
iconBuffer?: Buffer | null;
|
||||
generateSql?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<UploadResult> {
|
||||
const {
|
||||
classname,
|
||||
@@ -251,9 +250,7 @@ export async function uploadSingleFurni(params: {
|
||||
nitroBuffer,
|
||||
iconBuffer,
|
||||
generateSql = false,
|
||||
signal,
|
||||
} = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!/^[\w\-.*]+$/.test(classname)) {
|
||||
@@ -283,7 +280,6 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
signal?.throwIfAborted();
|
||||
const assetTargets = await getFurniAssetWriteTargets();
|
||||
const { iconDir, nitroDir } = assetTargets;
|
||||
|
||||
@@ -301,7 +297,6 @@ export async function uploadSingleFurni(params: {
|
||||
};
|
||||
}
|
||||
await fs.writeFile(nitroPath, nitroBuffer);
|
||||
signal?.throwIfAborted();
|
||||
|
||||
let iconFileName: string | null = null;
|
||||
let iconPath: string | null = null;
|
||||
@@ -312,7 +307,6 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
const mirroredPaths: string[] = [];
|
||||
signal?.throwIfAborted();
|
||||
await mirrorUploadedAsset(
|
||||
nitroPath,
|
||||
nitroFileName,
|
||||
@@ -352,7 +346,6 @@ export async function uploadSingleFurni(params: {
|
||||
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||
|
||||
let newId: number;
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await db.execute(sql`
|
||||
@@ -379,7 +372,6 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const furniEntry = buildFurniEntry({
|
||||
id: newId,
|
||||
classname,
|
||||
@@ -398,7 +390,6 @@ export async function uploadSingleFurni(params: {
|
||||
|
||||
let catalogItemId: number | null = null;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
// Skip when the item already has a catalog row — re-uploads must never
|
||||
// create a second row for the same item_ids.
|
||||
const [existingCatalog] = (await db.execute(sql`
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { toast } from "sonner";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
|
||||
export type SseEvent = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Read an SSE response body and invoke `onEvent` for each `data:` JSON payload.
|
||||
*/
|
||||
export async function readSseStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
onEvent: (event: SseEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
await reader.cancel();
|
||||
break;
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
try {
|
||||
onEvent(JSON.parse(part.slice(6)) as SseEvent);
|
||||
} catch {
|
||||
/* skip malformed events */
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST JSON to an admin SSE import endpoint and drive the standard
|
||||
* item_progress / batch_complete callbacks used by clothing & clone clients.
|
||||
*/
|
||||
export async function runSseImport(
|
||||
url: string,
|
||||
body: unknown,
|
||||
onDone: (classname: string) => void,
|
||||
onComplete: (succeeded: number, failed: number) => void,
|
||||
signal?: AbortSignal,
|
||||
onFailed?: (classname: string, error?: string) => void,
|
||||
): Promise<void> {
|
||||
const res = await adminFetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(
|
||||
typeof data.error === "string"
|
||||
? data.error
|
||||
: `Import failed (${res.status})`,
|
||||
);
|
||||
onComplete(0, 0);
|
||||
return;
|
||||
}
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
onComplete(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
await readSseStream(
|
||||
res.body,
|
||||
(evt) => {
|
||||
if (evt.type === "item_progress") {
|
||||
const classname = String(evt.classname ?? "");
|
||||
if (evt.status === "done") {
|
||||
onDone(classname);
|
||||
} else if (evt.status === "failed") {
|
||||
onFailed?.(classname, String(evt.error ?? ""));
|
||||
}
|
||||
}
|
||||
if (evt.type === "error") {
|
||||
toast.error(
|
||||
typeof evt.message === "string"
|
||||
? evt.message
|
||||
: "Import finished with errors",
|
||||
);
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
succeeded = Number(evt.succeeded ?? 0);
|
||||
failed = Number(evt.failed ?? 0);
|
||||
}
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
onComplete(succeeded, failed);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("staff smoke contract", () => {
|
||||
it("gates photos delete with PAGES_EDIT", () => {
|
||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
expect(src).toContain("PERMS.PAGES_EDIT");
|
||||
expect(src).toContain("deletePhoto");
|
||||
expect(src).toContain("logStaffActivity");
|
||||
expect(existsSync("src/app/admin/photos/page.tsx")).toBe(true);
|
||||
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
|
||||
"AdminPageShell",
|
||||
);
|
||||
});
|
||||
|
||||
it("gates bans create/lift", () => {
|
||||
expect(existsSync("src/app/admin/bans/page.tsx")).toBe(true);
|
||||
const bans = readFileSync("src/actions/admin-bans.ts", "utf8");
|
||||
expect(bans).toContain("liftBan");
|
||||
expect(bans).toContain("createBan");
|
||||
expect(readFileSync("src/app/admin/bans/page.tsx", "utf8")).toContain(
|
||||
"AdminPageShell",
|
||||
);
|
||||
});
|
||||
|
||||
it("wires ban appeal lift on help tickets", () => {
|
||||
const actions = readFileSync("src/actions/admin-help-tickets.ts", "utf8");
|
||||
expect(actions).toContain("liftBanFromHelpTicket");
|
||||
expect(actions).toContain("PERMS.USERS_BAN");
|
||||
const detail = readFileSync(
|
||||
"src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx",
|
||||
"utf8",
|
||||
);
|
||||
expect(detail).toContain("liftBanFromHelpTicket");
|
||||
expect(detail).toContain("canLiftBan");
|
||||
});
|
||||
|
||||
it("exposes sanction timeline on user show", () => {
|
||||
expect(
|
||||
existsSync("src/app/admin/users/_components/user-sanction-timeline.tsx"),
|
||||
).toBe(true);
|
||||
expect(existsSync("src/app/admin/users/_lib/load-user-sanctions.ts")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/app/admin/users/show/[id]/page.tsx", "utf8"),
|
||||
).toContain("UserSanctionTimeline");
|
||||
});
|
||||
|
||||
it("applies CMS SQL from drizzle/migrations (not Prisma)", () => {
|
||||
expect(existsSync("drizzle/migrations")).toBe(true);
|
||||
expect(existsSync("prisma")).toBe(false);
|
||||
expect(readFileSync("scripts/apply-migrations.ts", "utf8")).toContain(
|
||||
"drizzle/migrations",
|
||||
);
|
||||
const pkg = readFileSync("package.json", "utf8");
|
||||
expect(pkg).not.toContain('"prisma"');
|
||||
expect(pkg).not.toContain('"@prisma/client"');
|
||||
expect(pkg).not.toContain("prisma:generate");
|
||||
expect(pkg).toContain('"db:generate": "drizzle-kit generate"');
|
||||
expect(pkg).toContain('"db:studio": "drizzle-kit studio"');
|
||||
expect(pkg).toContain('"db:migrate": "tsx scripts/apply-migrations.ts"');
|
||||
expect(readFileSync("drizzle.config.ts", "utf8")).toContain(
|
||||
"./drizzle/drafts",
|
||||
);
|
||||
});
|
||||
|
||||
it("ships shared ops + ticket queue helpers", () => {
|
||||
expect(existsSync("src/lib/admin/ops-health.ts")).toBe(true);
|
||||
const ops = readFileSync("src/lib/admin/ops-health.ts", "utf8");
|
||||
expect(ops).toContain("redisOk");
|
||||
expect(ops).toContain("redis.ping");
|
||||
expect(existsSync("src/lib/admin/ticket-queue-counts.ts")).toBe(true);
|
||||
expect(existsSync("src/lib/admin/ops-online-users.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("ships dynamic admin menu overlay", () => {
|
||||
expect(existsSync("src/lib/admin-nav-config.ts")).toBe(true);
|
||||
expect(existsSync("src/app/admin/menu/page.tsx")).toBe(true);
|
||||
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
|
||||
"/admin/menu",
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/components/admin/admin-sidebar-nav.tsx", "utf8"),
|
||||
).toContain("applyAdminNavConfig");
|
||||
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).toContain(
|
||||
"ADMIN_NAV_CONFIG_KEY",
|
||||
);
|
||||
});
|
||||
|
||||
it("opts admin/mod out of static caching via instant=false", () => {
|
||||
// The Cache Components migration replaced `force-dynamic` with
|
||||
// `export const instant = false` — the legacy export must not survive.
|
||||
let legacyHits = 0;
|
||||
function walk(dir: string) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p);
|
||||
else if (/\.(tsx?|jsx?)$/.test(name)) {
|
||||
const src = readFileSync(p, "utf8");
|
||||
if (
|
||||
/export\s+const\s+dynamic\s*=\s*["']force-dynamic["']/.test(src)
|
||||
) {
|
||||
legacyHits++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("src/app/admin");
|
||||
walk("src/app/mod");
|
||||
expect(legacyHits).toBe(0);
|
||||
|
||||
// The root layout carries the single Cache Components opt-out; staff
|
||||
// pages inherit it (was force-dynamic, then per-layout instant=false).
|
||||
expect(readFileSync("src/app/layout.tsx", "utf8")).toContain(
|
||||
"export const instant = false",
|
||||
);
|
||||
// Staff layouts must not redeclare the opt-out — that is the root
|
||||
// layout's job now, and redeclaring it is the legacy per-layout pattern.
|
||||
expect(readFileSync("src/app/admin/layout.tsx", "utf8")).not.toContain(
|
||||
"export const instant",
|
||||
);
|
||||
expect(readFileSync("src/app/mod/layout.tsx", "utf8")).not.toContain(
|
||||
"export const instant",
|
||||
);
|
||||
});
|
||||
|
||||
it("caches analytics via redisCache", () => {
|
||||
for (const path of [
|
||||
"src/app/admin/analytics/page.tsx",
|
||||
"src/app/admin/analytics/activity/page.tsx",
|
||||
"src/app/admin/analytics/economy/page.tsx",
|
||||
]) {
|
||||
expect(readFileSync(path, "utf8"), path).toContain("redisCache");
|
||||
}
|
||||
});
|
||||
|
||||
it("exports bulk adjust currency and trade lock", () => {
|
||||
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
|
||||
expect(src).toContain("bulkAdjustCurrency");
|
||||
expect(src).toContain("setTradeLock");
|
||||
expect(src).toContain("tradeLockedUntil");
|
||||
expect(src).toContain("UsersSettings");
|
||||
expect(src).toContain("rcon.setTradeLock");
|
||||
});
|
||||
|
||||
it("deletes photos via Drizzle with local file purge helper", () => {
|
||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
expect(src).toContain("CameraWeb");
|
||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
||||
expect(src).toContain("@/lib/admin/photo-files");
|
||||
expect(src).toContain("@/lib/db");
|
||||
});
|
||||
|
||||
it("guards dual ticket queues on admin and mod", () => {
|
||||
for (const path of [
|
||||
"src/app/admin/tickets/desk/page.tsx",
|
||||
"src/app/admin/help-tickets/page.tsx",
|
||||
"src/app/mod/tickets/desk/page.tsx",
|
||||
"src/app/mod/help-tickets/page.tsx",
|
||||
"src/app/admin/tickets/[id]/page.tsx",
|
||||
"src/app/admin/help-tickets/[id]/page.tsx",
|
||||
"src/app/mod/tickets/[id]/page.tsx",
|
||||
"src/app/mod/help-tickets/[id]/page.tsx",
|
||||
]) {
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
expect(readFileSync(path, "utf8"), path).toContain("TicketQueueBanner");
|
||||
expect(readFileSync(path, "utf8"), path).toContain(
|
||||
"fetchTicketQueueOpenCounts",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("ships unified ticket inbox over both queues", () => {
|
||||
expect(existsSync("src/lib/admin/ticket-inbox.ts")).toBe(true);
|
||||
expect(readFileSync("src/lib/admin/ticket-inbox.ts", "utf8")).toContain(
|
||||
"fetchUnifiedTicketInbox",
|
||||
);
|
||||
expect(existsSync("src/components/admin/unified-tickets-table.tsx")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(readFileSync("src/app/admin/tickets/page.tsx", "utf8")).toContain(
|
||||
"fetchUnifiedTicketInbox",
|
||||
);
|
||||
expect(readFileSync("src/app/mod/tickets/page.tsx", "utf8")).toContain(
|
||||
"fetchUnifiedTicketInbox",
|
||||
);
|
||||
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
|
||||
"ticketInbox",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps StatusCard server-safe without client boundary", () => {
|
||||
expect(
|
||||
readFileSync("src/components/admin/dashboard.tsx", "utf8"),
|
||||
).not.toMatch(/^["']use client["']/m);
|
||||
expect(existsSync("src/components/admin/online-users-widget.tsx")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/components/admin/online-users-widget.tsx", "utf8"),
|
||||
).toContain("use client");
|
||||
expect(existsSync("src/app/admin/ads/edit-ad-delete-button.tsx")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ships ops health alerts, optional DB backup, and health rate limit", () => {
|
||||
const worker = readFileSync("scripts/jobs-worker.ts", "utf8");
|
||||
expect(worker).toContain("checkOpsHealth");
|
||||
expect(worker).toContain("healthDegraded");
|
||||
expect(worker).toContain("backupDatabase");
|
||||
expect(readFileSync("src/lib/services/alert.ts", "utf8")).toContain(
|
||||
"healthDegraded",
|
||||
);
|
||||
expect(readFileSync("src/app/api/health/route.ts", "utf8")).toContain(
|
||||
"rateLimit",
|
||||
);
|
||||
expect(readFileSync("src/actions/admin-alerts.ts", "utf8")).toContain(
|
||||
"markAllAlertsRead",
|
||||
);
|
||||
expect(readFileSync("src/env.ts", "utf8")).toContain("DB_BACKUP_DIR");
|
||||
});
|
||||
|
||||
it("documents local-only photo file purge", () => {
|
||||
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
|
||||
"purgeHint",
|
||||
);
|
||||
expect(readFileSync("src/messages/en.json", "utf8")).toContain(
|
||||
"External CDN URLs are not purged",
|
||||
);
|
||||
});
|
||||
|
||||
it("ships items base admin CRUD", () => {
|
||||
expect(existsSync("src/app/admin/items/page.tsx")).toBe(true);
|
||||
expect(existsSync("src/app/admin/items/[id]/page.tsx")).toBe(true);
|
||||
expect(readFileSync("src/actions/items-base.ts", "utf8")).toContain(
|
||||
"updateItemsBase",
|
||||
);
|
||||
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
|
||||
"/admin/items",
|
||||
);
|
||||
});
|
||||
|
||||
it("ships studio hub with synced badge import path", () => {
|
||||
const nav = readFileSync("src/lib/admin-nav.ts", "utf8");
|
||||
expect(nav).toContain('href: "/admin/studio"');
|
||||
expect(nav).toContain('labelKey: "studio"');
|
||||
expect(nav).toContain('"/admin/studio"');
|
||||
expect(existsSync("src/lib/services/import-badge.ts")).toBe(true);
|
||||
const badgeService = readFileSync(
|
||||
"src/lib/services/import-badge.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(badgeService).toContain("ExternalTexts.json");
|
||||
expect(badgeService).toContain("WebsiteBadges");
|
||||
expect(badgeService).toContain("importBadgeSynced");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user