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
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user