WIP: Unified Housekeeping rebuild / Gecombineerde Housekeeping-herbouw #53
No files matched your search
@@ -11,6 +11,23 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
runtime-diagnostics:
|
||||
if: gitea.event_name == 'workflow_dispatch'
|
||||
runs-on: shell
|
||||
steps:
|
||||
- name: Read recent CMS runtime errors
|
||||
run: |
|
||||
set -e
|
||||
echo "--- Recent CMS runtime errors ---"
|
||||
pm2 logs next --lines 250 --nostream 2>&1 \
|
||||
| grep -Ei 'error|permissions|permission_ranks|permission_definitions|unknown column|doesn.t exist|digest' \
|
||||
| tail -120 \
|
||||
|| true
|
||||
echo "--- Permission page database probe ---"
|
||||
cd /var/www/atom-nexst
|
||||
pnpm diag:permissions
|
||||
|
||||
|
||||
check:
|
||||
if: startsWith(gitea.ref_name, 'v') == false
|
||||
runs-on: shell
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
INSERT INTO `acl_permissions` (`slug`, `title`)
|
||||
VALUES ('housekeeping.preview.access', 'Access Housekeeping preview')
|
||||
ON DUPLICATE KEY UPDATE `title` = VALUES(`title`);
|
||||
+16
-15
@@ -5,7 +5,7 @@
|
||||
"engines": {
|
||||
"node": ">=26.8.1 <27"
|
||||
},
|
||||
"packageManager": "[email protected]4.0",
|
||||
"packageManager": "[email protected]5.0+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -15,6 +15,7 @@
|
||||
"biome:lint": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"knip": "knip --include files,dependencies,devDependencies,unlisted,binaries",
|
||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -47,43 +48,43 @@
|
||||
"jsonc-parser": "3.3.1",
|
||||
"jszip": "3.10.1",
|
||||
"lenis": "1.3.26",
|
||||
"lucide-react": "1.33.0",
|
||||
"lucide-react": "1.38.0",
|
||||
"lzma-wasm": "1.0.7",
|
||||
"motion": "13.1.1",
|
||||
"music-metadata": "11.15.0",
|
||||
"mysql2": "3.23.4",
|
||||
"mysql2": "3.24.2",
|
||||
"next": "16.3.3",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.13.7",
|
||||
"otplib": "13.4.1",
|
||||
"next-intl": "4.14.1",
|
||||
"otplib": "13.5.0",
|
||||
"pino": "10.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.85.0",
|
||||
"resend": "6.21.0",
|
||||
"react-hook-form": "7.87.0",
|
||||
"resend": "6.25.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"sharp": "^0.35.4",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"zod": "4.4.3"
|
||||
"zod": "4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.9",
|
||||
"@biomejs/biome": "2.5.11",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@types/node": "26.2.0",
|
||||
"@types/node": "26.4.0",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"husky": "9.1.7",
|
||||
"knip": "6.32.2",
|
||||
"lint-staged": "17.3.0",
|
||||
"knip": "6.33.0",
|
||||
"lint-staged": "17.4.1",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postcss": "^8.5.26",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsx": "4.23.12",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
|
||||
Generated
+544
-529
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -56,12 +56,12 @@ overrides:
|
||||
uuid: "^11.1.0"
|
||||
fast-uri: "^3.1.3"
|
||||
"@hono/node-server": "^1.19.13"
|
||||
postcss: "^8.5.25"
|
||||
postcss: "^8.5.26"
|
||||
tmp: "^0.2.6"
|
||||
sharp: "^0.35.3"
|
||||
sharp: "^0.35.4"
|
||||
brace-expansion: "^5.0.8"
|
||||
"@types/react": "19.2.18"
|
||||
"@types/react-dom": "19.2.4"
|
||||
"@types/react-dom": "19.2.5"
|
||||
# Tijdelijke mitigatie voor drizzle-kit audit
|
||||
esbuild: "^0.25.9"
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function PermissionRankError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<Card className="border-[var(--admin-error-border)]">
|
||||
<CardHeader>
|
||||
<CardTitle>Permission page error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The rank editor could not be rendered. The technical detail below is
|
||||
visible only inside this protected admin route.
|
||||
</p>
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 text-xs">
|
||||
{error.stack ?? error.message}
|
||||
</pre>
|
||||
{error.digest ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reference: {error.digest}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="button" onClick={reset}>
|
||||
Try again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-client";
|
||||
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -28,6 +29,17 @@ export default async function StudioSyncPage(_props: {
|
||||
</p>
|
||||
<SyncAllClient />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-muted-foreground mb-2">
|
||||
Official Furnidata
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan the official Habbo furnidata and automatically import any
|
||||
furniture that your hotel doesn't have yet.
|
||||
</p>
|
||||
<OfficialSyncClient />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,97 @@
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { db, ItemsBase } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { importSingleFurni } from "@/lib/services/furni-import";
|
||||
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { runSseBatch } from "@/lib/services/import/core/sse-batch";
|
||||
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
// Haal alle officiële furnidata
|
||||
const furnidata = await getOfficialHabboFurnidata();
|
||||
|
||||
// Haal alle bestaande classnames uit de database
|
||||
const existingClassnames = new Set(
|
||||
await db
|
||||
.select({ itemName: ItemsBase.itemName })
|
||||
.from(ItemsBase)
|
||||
.then((rows) => rows.map((r) => r.itemName)),
|
||||
);
|
||||
|
||||
// Filter naar alleen items die niet in de database staan
|
||||
const missingItems = Array.from(furnidata.entries())
|
||||
.filter(([, entry]) => !existingClassnames.has(entry.classname))
|
||||
.map(([, entry]) => ({
|
||||
classname: entry.classname,
|
||||
name: entry.name,
|
||||
description: entry.description ?? "",
|
||||
type: entry.category === "wallitem" ? "wallitem" : "flooritem",
|
||||
revision: entry.revision ?? 0,
|
||||
category: entry.category ?? "unknown",
|
||||
}));
|
||||
|
||||
if (missingItems.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "All items are already in the database",
|
||||
imported: 0,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (missingItems.length > 500) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: `Too many missing items (${missingItems.length}). Consider importing in smaller batches.`,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Importeren via SSE batch
|
||||
return runSseBatch({
|
||||
items: missingItems,
|
||||
concurrency: 3,
|
||||
signal: request.signal,
|
||||
labelOf: (it) => it.classname,
|
||||
worker: async (it, _index, report) => {
|
||||
const result = await importSingleFurni({
|
||||
id: 0,
|
||||
classname: it.classname,
|
||||
name: it.name,
|
||||
description: it.description,
|
||||
type: it.type,
|
||||
revision: it.revision,
|
||||
category: it.category,
|
||||
onProgress: report,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
warnings: result.warnings,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
warnings: result.warnings,
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
flush: async () => {
|
||||
// FurnitureData.json wordt automatisch geschreven door importSingleFurni
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -2,12 +2,26 @@ import { notFound } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { env } from "@/env";
|
||||
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
export default async function AdminNextLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const hasProductionAccess =
|
||||
env.NODE_ENV === "production" && env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED
|
||||
? (await getHousekeepingCapabilityContext()).has(
|
||||
PERMS.HOUSEKEEPING_PREVIEW_ACCESS,
|
||||
)
|
||||
: false;
|
||||
|
||||
export default function AdminNextLayout({ children }: { children: ReactNode }) {
|
||||
if (
|
||||
!isHousekeepingPreviewEnabled({
|
||||
nodeEnv: env.NODE_ENV,
|
||||
flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
|
||||
hasProductionAccess,
|
||||
})
|
||||
) {
|
||||
notFound();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,10 @@ export interface FurniItem {
|
||||
category: string;
|
||||
alreadyImported: boolean;
|
||||
nitroExists: boolean;
|
||||
/** Has an entry in FurnitureData.json */
|
||||
hasFurnidata?: boolean;
|
||||
/** Has an entry in catalog_items (SQL) */
|
||||
hasCatalogEntry?: boolean;
|
||||
/** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,24 @@ import { isHousekeepingPreviewEnabled } from "./preview-gate";
|
||||
|
||||
describe("isHousekeepingPreviewEnabled", () => {
|
||||
it.each([
|
||||
["development", true, true],
|
||||
["test", true, true],
|
||||
["development", false, false],
|
||||
["production", true, false],
|
||||
["production", false, false],
|
||||
] as const)("NODE_ENV=%s flag=%s => %s", (nodeEnv, flag, expected) => {
|
||||
expect(isHousekeepingPreviewEnabled({ nodeEnv, flag })).toBe(expected);
|
||||
});
|
||||
["development", true, false, true],
|
||||
["test", true, false, true],
|
||||
["development", false, true, false],
|
||||
["production", true, true, true],
|
||||
["production", true, false, false],
|
||||
["production", false, true, false],
|
||||
] as const)(
|
||||
"NODE_ENV=%s flag=%s productionAccess=%s => %s",
|
||||
(nodeEnv, flag, hasProductionAccess, expected) => {
|
||||
expect(
|
||||
isHousekeepingPreviewEnabled({
|
||||
nodeEnv,
|
||||
flag,
|
||||
hasProductionAccess,
|
||||
}),
|
||||
).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export function isHousekeepingPreviewEnabled(input: {
|
||||
nodeEnv: "development" | "test" | "production";
|
||||
flag: boolean;
|
||||
hasProductionAccess?: boolean;
|
||||
}): boolean {
|
||||
return input.nodeEnv !== "production" && input.flag;
|
||||
return (
|
||||
input.flag &&
|
||||
(input.nodeEnv !== "production" || input.hasProductionAccess === true)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Instrumentation } from "next";
|
||||
|
||||
export const onRequestError: Instrumentation.onRequestError = async (
|
||||
error,
|
||||
request,
|
||||
context,
|
||||
) => {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
const { logServerError } = await import("@/lib/server-log");
|
||||
logServerError("next.request_failed", error, {
|
||||
path: request.path.split("?", 1)[0] ?? request.path,
|
||||
method: request.method,
|
||||
routePath: context.routePath,
|
||||
routeType: context.routeType,
|
||||
renderSource: context.renderSource,
|
||||
digest:
|
||||
typeof error === "object" && error !== null && "digest" in error
|
||||
? String(error.digest)
|
||||
: null,
|
||||
});
|
||||
};
|
||||
+6
-13
@@ -1,17 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_IMAGER_URL: "https://img.example.com" as string | undefined,
|
||||
NEXT_PUBLIC_APP_URL: undefined as string | undefined,
|
||||
IMAGING_UPSTREAM_URL: "https://up.example.com" as string | undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => envMock);
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { avatarImageUrl, excerpt, slugify } from "./format";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("slugify", () => {
|
||||
it("lowercases and hyphenates", () => {
|
||||
expect(slugify("Hello World!")).toBe("hello-world");
|
||||
@@ -26,7 +19,7 @@ describe("slugify", () => {
|
||||
|
||||
describe("avatarImageUrl", () => {
|
||||
beforeEach(() => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com");
|
||||
});
|
||||
|
||||
it("appends the figure and options", () => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const originalDatabaseUrl = process.env.DATABASE_URL;
|
||||
const originalHotelName = process.env.HOTEL_NAME;
|
||||
const originalSkipValidation = process.env.SKIP_ENV_VALIDATION;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
||||
else process.env.DATABASE_URL = originalDatabaseUrl;
|
||||
if (originalHotelName === undefined) delete process.env.HOTEL_NAME;
|
||||
else process.env.HOTEL_NAME = originalHotelName;
|
||||
if (originalSkipValidation === undefined)
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
else process.env.SKIP_ENV_VALIDATION = originalSkipValidation;
|
||||
});
|
||||
|
||||
describe("imager client boundary", () => {
|
||||
it("loads without server-only environment variables", async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
delete process.env.HOTEL_NAME;
|
||||
vi.resetModules();
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
|
||||
const imager = await import("./imager");
|
||||
|
||||
expect(imager.getAvatarUrl("hd-180-1")).toContain("figure=hd-180-1");
|
||||
});
|
||||
});
|
||||
+10
-17
@@ -1,20 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_IMAGER_URL: "https://img.example.com" as string | undefined,
|
||||
NEXT_PUBLIC_APP_URL: undefined as string | undefined,
|
||||
IMAGING_UPSTREAM_URL: "https://up.example.com" as string | undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => envMock);
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getAvatarUrl, resolveImagerBase } from "./imager";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("resolveImagerBase", () => {
|
||||
beforeEach(() => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com");
|
||||
});
|
||||
|
||||
it("returns configured imager env var", () => {
|
||||
@@ -22,12 +15,12 @@ describe("resolveImagerBase", () => {
|
||||
});
|
||||
|
||||
it("strips trailing slashes", () => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com/";
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com/");
|
||||
expect(resolveImagerBase()).toBe("https://img.example.com");
|
||||
});
|
||||
|
||||
it("falls back to epicnabbo.nl when env var is not set", () => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "");
|
||||
expect(resolveImagerBase()).toBe(
|
||||
"https://epicnabbo.nl/imaging/avatarimage",
|
||||
);
|
||||
@@ -36,7 +29,7 @@ describe("resolveImagerBase", () => {
|
||||
|
||||
describe("getAvatarUrl", () => {
|
||||
beforeEach(() => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com");
|
||||
});
|
||||
|
||||
it("builds a query string with defaults (omits default params)", () => {
|
||||
@@ -83,7 +76,7 @@ describe("getAvatarUrl", () => {
|
||||
});
|
||||
|
||||
it("uses epicnabbo.nl when no env var is set", () => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "");
|
||||
const url = getAvatarUrl("hd-180-1");
|
||||
expect(url.startsWith("https://epicnabbo.nl/imaging/avatarimage?")).toBe(
|
||||
true,
|
||||
|
||||
+1
-2
@@ -7,7 +7,6 @@
|
||||
|
||||
export type { AvatarOptions } from "@/types/admin";
|
||||
|
||||
import { env } from "@/env";
|
||||
import type { AvatarOptions } from "@/types/admin";
|
||||
|
||||
const DEFAULT_IMAGER_URL = "https://epicnabbo.nl/imaging/avatarimage";
|
||||
@@ -17,7 +16,7 @@ const DEFAULT_IMAGER_URL = "https://epicnabbo.nl/imaging/avatarimage";
|
||||
* Falls back to epicnabbo.nl if not configured.
|
||||
*/
|
||||
export function resolveImagerBase(): string {
|
||||
const fromEnv = env.NEXT_PUBLIC_IMAGER_URL?.trim();
|
||||
const fromEnv = process.env.NEXT_PUBLIC_IMAGER_URL?.trim();
|
||||
if (!fromEnv) {
|
||||
return DEFAULT_IMAGER_URL;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// (modules.ts → sidebar.tsx) without pulling in db/server-only deps.
|
||||
|
||||
export const PERMS = {
|
||||
HOUSEKEEPING_PREVIEW_ACCESS: "housekeeping.preview.access",
|
||||
ADMIN_DASHBOARD: "admin.dashboard",
|
||||
USERS_VIEW: "admin.users.view",
|
||||
USERS_EDIT: "admin.users.edit",
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
fetchEmulatorRankForEdit,
|
||||
getRankPermissionColumn,
|
||||
RANK_GENERAL_FIELDS,
|
||||
splitRankUpdateFields,
|
||||
toPermissionValue,
|
||||
} from "./permission-ranks";
|
||||
|
||||
const dialect = new MySqlDialect();
|
||||
|
||||
function queryText(query: SQL | string) {
|
||||
return typeof query === "string" ? query : dialect.sqlToQuery(query).sql;
|
||||
}
|
||||
|
||||
describe("getRankPermissionColumn", () => {
|
||||
it("returns rank_N for valid rank", () => {
|
||||
expect(getRankPermissionColumn(1)).toBe("rank_1");
|
||||
@@ -76,3 +85,54 @@ describe("RANK_GENERAL_FIELDS", () => {
|
||||
expect(RANK_GENERAL_FIELDS.has("nonexistent")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchEmulatorRankForEdit", () => {
|
||||
it("loads permissions from the legacy table when permission_definitions is absent", async () => {
|
||||
const db = {
|
||||
execute: async (query: SQL | string) => {
|
||||
const text = queryText(query);
|
||||
if (text.includes("FROM permission_ranks")) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
rank_name: "Developer",
|
||||
level: 11,
|
||||
},
|
||||
],
|
||||
[],
|
||||
];
|
||||
}
|
||||
if (text.includes("FROM information_schema.columns")) {
|
||||
return [[], []];
|
||||
}
|
||||
if (text.includes("FROM permissions")) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
rank_name: "Developer",
|
||||
level: 11,
|
||||
cmd_ban: 1,
|
||||
cmd_owner: 2,
|
||||
},
|
||||
],
|
||||
[],
|
||||
];
|
||||
}
|
||||
throw new Error(`Unexpected query: ${text}`);
|
||||
},
|
||||
transaction: async () => {
|
||||
throw new Error("Unexpected transaction");
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchEmulatorRankForEdit(db as never, 11),
|
||||
).resolves.toMatchObject({
|
||||
id: 11,
|
||||
permissions: { cmd_ban: "1", cmd_owner: "2" },
|
||||
permissionMaxValues: { cmd_ban: 1, cmd_owner: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,10 @@ export async function fetchEmulatorRankForEdit(
|
||||
if (rankRows.length === 0) return null;
|
||||
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
const permissions: Record<string, string> = {};
|
||||
const permissionMaxValues: Record<string, number> = {};
|
||||
const definitionColumns = await getTableColumns(db, "permission_definitions");
|
||||
if (definitionColumns.has(rankColumn)) {
|
||||
const permissionRows = await rawRows<
|
||||
{
|
||||
permission_key: string;
|
||||
@@ -140,11 +144,32 @@ export async function fetchEmulatorRankForEdit(
|
||||
`,
|
||||
);
|
||||
|
||||
const permissions: Record<string, string> = {};
|
||||
const permissionMaxValues: Record<string, number> = {};
|
||||
for (const row of permissionRows) {
|
||||
permissions[row.permission_key] = String(row.value ?? 0);
|
||||
permissionMaxValues[row.permission_key] = Number(row.max_value ?? 1);
|
||||
for (const permission of permissionRows) {
|
||||
permissions[permission.permission_key] = String(permission.value ?? 0);
|
||||
permissionMaxValues[permission.permission_key] = Number(
|
||||
permission.max_value ?? 1,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const legacyRows = await rawRows<Record<string, unknown>[]>(
|
||||
db,
|
||||
sql`SELECT * FROM permissions WHERE id = ${rankId} LIMIT 1`,
|
||||
);
|
||||
for (const [key, value] of Object.entries(legacyRows[0] ?? {})) {
|
||||
if (
|
||||
key === "id" ||
|
||||
RANK_GENERAL_FIELDS.has(key) ||
|
||||
!VALID_KEY.test(key)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const permissionValue =
|
||||
typeof value === "string" || typeof value === "number"
|
||||
? toPermissionValue(value)
|
||||
: 0;
|
||||
permissions[key] = String(permissionValue);
|
||||
permissionMaxValues[key] = Math.max(1, permissionValue);
|
||||
}
|
||||
}
|
||||
|
||||
const row = rankRows[0];
|
||||
|
||||
Reference in new issue
Block a user