WIP: Unified Housekeeping rebuild / Gecombineerde Housekeeping-herbouw #53

Closed
Simo wants to merge 68 commits from codex/housekeeping-rebuild-stepwise into main
pull from: codex/housekeeping-rebuild-stepwise
25 changed files with 1832 additions and 621 deletions
Showing only changes of commit 52e00854a2 - Show all commits

No files matched your search

+17
View File
@@ -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
View File
@@ -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"
}
+544 -529
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -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
+42
View File
@@ -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>
);
}
+12
View File
@@ -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
},
});
},
);
+15 -1
View File
@@ -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)
);
}
+22
View File
@@ -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
View File
@@ -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", () => {
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -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",
+60
View File
@@ -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 },
});
});
});
+30 -5
View File
@@ -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];