Merge remote-tracking branch 'origin/main' into codex/housekeeping-rebuild-stepwise

# Conflicts:
#	.env.example
#	src/features/housekeeping/foundation/preview-route-contract.test.ts
This commit is contained in:
Simo committed 2026-09-01 18:19:36 +02:00
commit 52e00854a2
25 files changed
+1865 -654

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"
+116
View File
@@ -0,0 +1,116 @@
import "./load-env";
import mysql, { type RowDataPacket } from "mysql2/promise";
import { mysqlConnectionUrl } from "./db-url";
type DbError = Error & { code?: string };
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error("DATABASE_URL is required");
const connection = await mysql.createConnection(
mysqlConnectionUrl(databaseUrl),
);
let failed = false;
async function probe(
name: string,
statement: string,
values: Array<string | number> = [],
): Promise<RowDataPacket[] | null> {
try {
const [rows] = await connection.execute<RowDataPacket[]>(statement, values);
console.log(`[permissions-diag] ${name}: ok (${rows.length} rows)`);
return rows;
} catch (error) {
failed = true;
const dbError = error as DbError;
console.error(
`[permissions-diag] ${name}: failed code=${dbError.code ?? "unknown"} message=${dbError.message}`,
);
return null;
}
}
try {
const rankRows = await probe(
"select rank id",
"SELECT id FROM permission_ranks ORDER BY id DESC LIMIT 1",
);
const rankId = Number(rankRows?.[0]?.id ?? 11);
console.log(`[permissions-diag] probing rank id ${rankId}`);
await probe(
"rank detail",
`SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank,
log_commands, room_effect, auto_credits_amount, auto_pixels_amount,
auto_gotw_amount, auto_points_amount
FROM permission_ranks WHERE id = ?`,
[rankId],
);
const definitionColumns = await probe(
"permission_definitions columns",
`SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'permission_definitions'`,
);
const permissionLimits = await probe(
"permission max_value limits",
`SELECT MIN(max_value) AS min_value,
MAX(max_value) AS max_value,
SUM(max_value > 20) AS values_over_20
FROM permission_definitions`,
);
if (permissionLimits?.[0]) {
console.log(
`[permissions-diag] max_value range ${permissionLimits[0].min_value}..${permissionLimits[0].max_value}; values_over_20=${permissionLimits[0].values_over_20}`,
);
}
const rankColumn = `rank_${rankId}`;
if (definitionColumns?.some((row) => row.column_name === rankColumn)) {
await probe(
"normalized permissions",
`SELECT permission_key, max_value, \`${rankColumn}\` AS value
FROM permission_definitions ORDER BY permission_key ASC`,
);
} else {
await probe(
"legacy permissions",
"SELECT * FROM permissions WHERE id = ? LIMIT 1",
[rankId],
);
}
await probe(
"rank users",
"SELECT id, username, look FROM users WHERE rank = ? ORDER BY username LIMIT 200",
[rankId],
);
await probe(
"rank user count",
"SELECT COUNT(*) AS total FROM users WHERE rank = ?",
[rankId],
);
await probe(
"CMS permissions",
"SELECT id, slug, title FROM acl_permissions ORDER BY slug",
);
const roles = await probe(
"CMS role",
"SELECT id FROM acl_roles WHERE slug = ? LIMIT 1",
[`rank_${rankId}`],
);
if (roles?.[0]?.id) {
await probe(
"CMS role permissions",
`SELECT p.slug FROM acl_model_permissions mp
INNER JOIN acl_permissions p ON mp.permission_id = p.id
WHERE mp.model_id = ?`,
[roles[0].id],
);
}
} finally {
await connection.end();
}
if (failed) process.exitCode = 1;
@@ -0,0 +1,225 @@
"use client";
import { AlertCircle, Check, CloudDownload, Loader2 } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
interface SyncEvent {
type: string;
classname?: string;
status?: string;
succeeded?: number;
failed?: number;
message?: string;
}
interface SyncResult {
succeeded: number;
failed: number;
message: string;
}
/**
* Official furnidata sync client.
* Imports all furniture from official Habbo furnidata that the hotel doesn't have yet.
*/
export function OfficialSyncClient() {
const [syncing, setSyncing] = useState(false);
const [progress, setProgress] = useState<{
done: number;
total: number;
} | null>(null);
const [log, setLog] = useState<string[]>([]);
const [result, setResult] = useState<SyncResult | null>(null);
const abortRef = useRef<AbortController | null>(null);
const startSync = useCallback(async () => {
setSyncing(true);
setProgress(null);
setLog([]);
setResult(null);
const abort = new AbortController();
abortRef.current = abort;
try {
const res = await adminFetch("/api/admin/import/official/sync-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
signal: abort.signal,
});
if (!res.body) {
toast.error("No response stream");
setSyncing(false);
return;
}
if (!res.headers.get("content-type")?.includes("text/event-stream")) {
let errMsg = "Sync failed";
try {
const json = await res.json();
errMsg = json.message || errMsg;
} catch {
// ignore
}
toast.error(errMsg);
setSyncing(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let succeeded = 0;
let failed = 0;
let totalItems = 0;
while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) 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 {
const evt = JSON.parse(part.slice(6)) as SyncEvent;
if (evt.type === "item_progress") {
const classname = evt.classname ?? "";
if (evt.status === "done") {
succeeded++;
totalItems++;
setProgress({ done: succeeded + failed, total: totalItems });
setLog((prev) => [...prev, `✓ ${classname}`]);
} else if (evt.status === "failed") {
failed++;
totalItems++;
setProgress({ done: succeeded + failed, total: totalItems });
setLog((prev) => [...prev, `✗ ${classname} (failed)`]);
}
}
if (evt.type === "batch_complete") {
succeeded = evt.succeeded ?? succeeded;
failed = evt.failed ?? failed;
}
if (evt.type === "error") {
toast.error(
typeof evt.message === "string"
? evt.message
: "Sync finished with errors",
);
}
} catch {
// skip parse errors
}
}
}
setResult({
succeeded,
failed,
message:
totalItems > 0
? `${succeeded} items synced`
: "All items were already up to date",
});
if (succeeded > 0) toast.success(`${succeeded} items synced`);
if (failed > 0) toast.error(`${failed} items failed`);
} catch (err) {
if ((err as Error).name === "AbortError") {
toast.info("Sync cancelled");
} else {
toast.error("Sync request failed");
}
} finally {
setSyncing(false);
abortRef.current = null;
}
}, []);
return (
<div className="space-y-4">
{!result && (
<div className="flex items-center gap-2 flex-wrap">
<Button onClick={startSync} disabled={syncing} size="lg">
{syncing ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<CloudDownload className="w-4 h-4 mr-2" />
)}
{syncing
? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}`
: "Import All Missing from Official Furnidata"}
</Button>
{syncing && (
<Button
variant="outline"
size="lg"
onClick={() => abortRef.current?.abort()}
>
Cancel
</Button>
)}
</div>
)}
{progress && (
<div className="text-sm text-muted-foreground">
Processed {progress.done} items
{progress.total > 0 ? ` out of ${progress.total}` : ""}
</div>
)}
{log.length > 0 && (
<div className="rounded-xl border bg-card p-4 max-h-80 overflow-x-auto overflow-y-auto space-y-0.5">
{log.map((line) => (
<div
key={line}
className="text-xs font-mono text-muted-foreground whitespace-nowrap"
>
{line}
</div>
))}
</div>
)}
{result && (
<div className="rounded-xl border bg-card p-5 space-y-3">
<div className="flex items-center gap-2">
{result.failed === 0 ? (
<Check className="w-5 h-5 text-[var(--admin-success)]" />
) : (
<AlertCircle className="w-5 h-5 text-[var(--admin-error)]" />
)}
<span className="text-lg font-semibold">Sync Complete</span>
</div>
<div className="text-sm space-y-1">
<p>
<span className="text-muted-foreground">Succeeded:</span>{" "}
{result.succeeded}
</p>
<p>
<span className="text-muted-foreground">Failed:</span>{" "}
{result.failed}
</p>
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
)}
{!syncing && !result && (
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground text-sm">
<CloudDownload className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>
Click the button above to scan the official Habbo furnidata and
automatically import any furniture that your hotel doesn't have yet.
</p>
</div>
)}
</div>
);
}
+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>
);
+73 -1
View File
@@ -11,6 +11,7 @@ import { getSource } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
appendFurniEntriesBulk,
readFurniData,
rebuildCatalogOfferIds,
reconcileFurniDataWithItemsBase,
verifyAndFixSpriteIds,
@@ -311,6 +312,38 @@ export const GET = withAdmin(
});
const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, "");
// Bulk-load FurnitureData and catalog references for status checks
const furniDataSet = new Set<string>();
const catalogIdSet = new Set<number>();
try {
const fd = (await readFurniData()) as {
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
};
for (const e of [
...(fd.roomitemtypes?.furnitype ?? []),
...(fd.wallitemtypes?.furnitype ?? []),
]) {
if (e.classname) furniDataSet.add(e.classname);
}
} catch {
/* best effort */
}
try {
const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`);
for (const row of (
rows as unknown as [Array<{ item_ids: string | null }>, unknown]
)[0]) {
for (const part of String(row.item_ids ?? "").split(/[;,]/)) {
const n = Number(part.trim());
if (Number.isFinite(n) && n > 0) catalogIdSet.add(n);
}
}
} catch {
/* best effort */
}
const enriched = items.map((item) => {
const imported = item.present;
let nitroExists = false;
@@ -336,6 +369,8 @@ export const GET = withAdmin(
category: String(item.category ?? ""),
alreadyImported: imported,
nitroExists,
hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined,
hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined,
iconUrl: sourceIconBase
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
: undefined,
@@ -364,6 +399,37 @@ export const GET = withAdmin(
allImported.map((e) => e.itemName).filter((n): n is string => !!n),
);
// Bulk-load FurnitureData and catalog references for status checks
const furniDataSet = new Set<string>();
const catalogIdSet = new Set<number>();
try {
const fd = (await readFurniData()) as {
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
};
for (const e of [
...(fd.roomitemtypes?.furnitype ?? []),
...(fd.wallitemtypes?.furnitype ?? []),
]) {
if (e.classname) furniDataSet.add(e.classname);
}
} catch {
/* best effort */
}
try {
const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`);
for (const row of (
rows as unknown as [Array<{ item_ids: string | null }>, unknown]
)[0]) {
for (const part of String(row.item_ids ?? "").split(/[;,]/)) {
const n = Number(part.trim());
if (Number.isFinite(n) && n > 0) catalogIdSet.add(n);
}
}
} catch {
/* best effort */
}
const result = await listFurniture({
search: search || undefined,
page,
@@ -386,7 +452,13 @@ export const GET = withAdmin(
);
nitroExists = existsSync(/*turbopackIgnore: true*/ p);
}
return { ...item, alreadyImported: imported, nitroExists };
return {
...item,
alreadyImported: imported,
nitroExists,
hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined,
hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined,
};
});
return apiOk({ items: enriched, meta: result.meta });
@@ -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();
+66 -9
View File
@@ -1,6 +1,7 @@
"use client";
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -32,6 +33,13 @@ function SummaryRow({
);
}
function formatEta(ms: number): string {
if (ms < 60_000) return `${Math.ceil(ms / 1000)}s`;
const min = Math.floor(ms / 60_000);
const sec = Math.ceil((ms % 60_000) / 1000);
return `${min}m ${sec}s`;
}
export function BatchProgress({
progress,
done,
@@ -39,8 +47,11 @@ export function BatchProgress({
failed,
cancelling,
verification,
startTime,
failedClassnames,
onCancel,
onDismiss,
onRetry,
}: {
progress: Map<string, BatchItemStatus>;
done: boolean;
@@ -48,8 +59,11 @@ export function BatchProgress({
failed: number;
cancelling?: boolean;
verification?: VerificationSummary | null;
startTime?: number;
failedClassnames?: string[];
onCancel?: () => void;
onDismiss?: () => void;
onRetry?: (classnames: string[]) => void;
}) {
const entries = [...progress.entries()];
const total = entries.length;
@@ -58,6 +72,27 @@ export function BatchProgress({
).length;
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
// Live ETA calculation
const [eta, setEta] = useState<string | null>(null);
useEffect(() => {
if (done || !startTime || completed === 0) {
setEta(null);
return;
}
const elapsed = Date.now() - startTime;
const avgPerItem = elapsed / completed;
const remaining = (total - completed) * avgPerItem;
setEta(formatEta(remaining));
const interval = setInterval(() => {
const elapsed = Date.now() - startTime;
const avgPerItem = elapsed / completed;
const remaining = (total - completed) * avgPerItem;
setEta(formatEta(remaining));
}, 2000);
return () => clearInterval(interval);
}, [done, startTime, completed, total]);
return (
<div className="border-t border-[var(--admin-border)] p-3">
<div className="mb-2 flex items-center justify-between gap-3">
@@ -81,6 +116,11 @@ export function BatchProgress({
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
{completed}/{total}
</span>
{eta && (
<span className="text-xs font-mono text-[var(--admin-accent)]">
~{eta}
</span>
)}
<Loader2
size={14}
className="animate-spin text-[var(--admin-accent)]"
@@ -90,11 +130,16 @@ export function BatchProgress({
</div>
{!done && (
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
<div
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
style={{ width: `${percent}%` }}
/>
<div className="mb-2 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
<div
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
<span className="text-xs font-mono font-bold text-[var(--admin-text)]">
{percent}%
</span>
</div>
)}
@@ -165,10 +210,22 @@ export function BatchProgress({
<div className="mt-2 flex justify-end gap-2">
{done ? (
<Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} />
Dismiss
</Button>
<>
{failedClassnames && failedClassnames.length > 0 && onRetry && (
<Button
size="sm"
variant="outline"
onClick={() => onRetry(failedClassnames)}
>
<Loader2 size={14} />
Retry {failedClassnames.length} failed
</Button>
)}
<Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} />
Dismiss
</Button>
</>
) : (
<Button
size="sm"
+437 -37
View File
@@ -30,6 +30,7 @@ import {
setFurnidataTranslateEnabled,
} from "@/actions/import-furni";
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -60,6 +61,7 @@ import {
previewAutoCatalog,
} from "@/lib/furni/auto-catalog";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { readSseStream } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress";
@@ -164,6 +166,20 @@ export function StudioClient({
const [deleting, setDeleting] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
// Confirm dialog for clone all
const { confirm, dialog: confirmDialog } = useConfirmDialog();
// Clone source stats (shown when a clone source is active)
const [cloneStats, setCloneStats] = useState<{
total: number;
present: number;
clonable: number;
} | null>(null);
const [cloneAllCancelling, setCloneAllCancelling] = useState(false);
const cloneAllAbortRef = useRef<AbortController | null>(null);
const batchStartTimeRef = useRef<number>(0);
const [failedClassnames, setFailedClassnames] = useState<string[]>([]);
// Nitro regeneration (missing .nitro)
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
new Set(),
@@ -237,17 +253,22 @@ export function StudioClient({
if (categoryFilter !== "all") {
result = result.filter((i) => i.category === categoryFilter);
}
if (statusFilter !== "all") {
if (statusFilter === "imported")
result = result.filter((i) => i.alreadyImported);
else if (statusFilter === "inCatalog")
result = result.filter((i) => i.alreadyImported && i.nitroExists);
else if (statusFilter === "missingNitro")
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
}
// Not imported filter - works independently (also with statusFilter === "all")
if (statusFilter === "notImported" || statusFilter === "all")
result = result.filter((i) => i.alreadyImported === false);
if (statusFilter === "imported")
result = result.filter((i) => i.alreadyImported);
else if (statusFilter === "inCatalog")
result = result.filter((i) => i.alreadyImported && i.nitroExists);
else if (statusFilter === "missingNitro")
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
else if (statusFilter === "missingFurnidata")
result = result.filter(
(i) => i.alreadyImported && i.hasFurnidata === false,
);
else if (statusFilter === "missingCatalog")
result = result.filter(
(i) => i.alreadyImported && i.hasCatalogEntry === false,
);
else if (statusFilter === "notImported")
result = result.filter((i) => !i.alreadyImported);
const sorted = [...result];
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
else if (sortBy === "classname")
@@ -325,6 +346,9 @@ export function StudioClient({
setItems([]);
setActiveSearch("");
setSearchTerm("");
setCloneStats(null);
setBatchProgress(null);
fetchCloneStats(sourceId);
};
const fetchSources = useCallback(async () => {
@@ -339,6 +363,183 @@ export function StudioClient({
}
}, []);
const fetchCloneStats = useCallback(async (sourceId: string) => {
if (!sourceId) {
setCloneStats(null);
return;
}
try {
const res = await adminFetch(
`/api/admin/import/clone?source=${encodeURIComponent(sourceId)}`,
);
if (res.ok) {
const data = await res.json();
setCloneStats(data.stats ?? null);
}
} catch {
/* ignore */
}
}, []);
async function cloneAllMissing() {
if (!activeSource) return;
// Fetch all clonable classnames
let classnames: string[] = [];
try {
const res = await adminFetch(
`/api/admin/import/clone?source=${encodeURIComponent(activeSource)}&action=clonable`,
);
const data = (await res.json()) as {
classnames?: string[];
error?: string;
};
if (!res.ok) {
toast.error(data.error || "Failed to fetch clonable items");
return;
}
classnames = data.classnames || [];
} catch {
toast.error("Network error fetching clonable items");
return;
}
if (classnames.length === 0) {
toast.info("Alles is al geïmporteerd voor deze bron");
return;
}
const sourceName =
sources.find((s) => s.id === activeSource)?.name || activeSource;
const confirmed = await confirm({
title: "Alles importeren",
description: `${classnames.length} items importeren uit "${sourceName}"? Dit kan even duren.`,
confirmLabel: "Alles importeren",
});
if (!confirmed) return;
// Initialize progress tracking as a Map for BatchProgress compatibility
const initial = new Map<string, BatchItemStatus>();
for (const cn of classnames) {
initial.set(cn, { classname: cn, status: "pending" });
}
setBatchProgress(new Map(initial));
setBatchDone(false);
setBatchSucceeded(0);
setBatchFailed(0);
setFailedClassnames([]);
setCloneAllCancelling(false);
batchStartTimeRef.current = Date.now();
const abort = new AbortController();
cloneAllAbortRef.current = abort;
const CHUNK = 400;
let done = 0;
let okCount = 0;
let failCount = 0;
try {
for (let i = 0; i < classnames.length; i += CHUNK) {
if (abort.signal.aborted) break;
const chunk = classnames.slice(i, i + CHUNK);
const res = await adminFetch("/api/admin/import/clone/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceId: activeSource,
classnames: chunk,
concurrency: 10,
}),
signal: abort.signal,
});
if (!res.ok || !res.body) {
toast.error(`Batch chunk mislukt (${res.status})`);
continue;
}
await readSseStream(
res.body,
(evt) => {
if (evt.type === "item_progress") {
const cn = String(evt.classname ?? "");
setBatchProgress((prev) => {
if (!prev) return prev;
const next = new Map(prev);
next.set(cn, {
classname: cn,
status: evt.status as BatchItemStatus["status"],
message: evt.message as string | undefined,
});
return next;
});
if (evt.status === "done" || evt.status === "failed") {
done++;
if (evt.status === "failed") {
setFailedClassnames((prev) =>
prev.includes(cn) ? prev : [...prev, cn],
);
}
}
}
if (evt.type === "batch_complete") {
okCount += Number(evt.succeeded ?? 0);
failCount += Number(evt.failed ?? 0);
}
if (evt.type === "error") {
toast.error(
typeof evt.message === "string" ? evt.message : "Import fout",
);
}
},
abort.signal,
);
}
if (!abort.signal.aborted) {
setBatchSucceeded(okCount);
setBatchFailed(failCount);
setBatchDone(true);
if (okCount > 0) {
toast.success(
`${okCount} items geïmporteerd${
failCount > 0 ? ` (${failCount} mislukt)` : ""
}`,
);
}
if (failCount > 0 && okCount === 0) {
toast.error(`${failCount} items mislukt`);
}
fetchItems(activeSearch, 1, activeSource);
fetchStats();
fetchCloneStats(activeSource);
// Auto-translate after clone all if translation is enabled
if (translate && okCount > 0) {
toast.info("Automatisch vertalen van alle talen...");
buildAllLanguages();
}
} else {
toast.info("Clone all geannuleerd");
}
} catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Clone all geannuleerd");
} else {
toast.error("Verbindingsfout tijdens clone all");
}
} finally {
cloneAllAbortRef.current = null;
setCloneAllCancelling(false);
setSelected(new Set());
}
}
useEffect(() => {
fetchItems("", 1, activeSource);
fetchStats();
@@ -492,6 +693,8 @@ export function StudioClient({
setBatchSucceeded(0);
setBatchFailed(0);
setBatchCancelling(false);
setFailedClassnames([]);
batchStartTimeRef.current = Date.now();
const initial = new Map<string, BatchItemStatus>();
for (const i of toImport) {
@@ -565,6 +768,12 @@ export function StudioClient({
: i,
),
);
} else if (event.status === "failed") {
setFailedClassnames((prev) =>
prev.includes(event.classname)
? prev
: [...prev, event.classname],
);
}
} else if (event.type === "batch_complete") {
setBatchSucceeded(event.succeeded);
@@ -607,6 +816,103 @@ export function StudioClient({
}
};
const cancelCloneAll = () => {
if (cloneAllAbortRef.current) {
setCloneAllCancelling(true);
cloneAllAbortRef.current.abort();
}
};
async function retryFailed(failed: string[]) {
if (!activeSource || failed.length === 0) return;
// For clone sources, re-run cloneAllMissing with only the failed classnames
setBatchProgress(new Map());
setBatchDone(false);
setBatchSucceeded(0);
setBatchFailed(0);
setFailedClassnames([]);
batchStartTimeRef.current = Date.now();
const initial = new Map<string, BatchItemStatus>();
for (const cn of failed) {
initial.set(cn, { classname: cn, status: "pending" });
}
setBatchProgress(new Map(initial));
const abort = new AbortController();
cloneAllAbortRef.current = abort;
let done = 0;
let okCount = 0;
let failCount = 0;
try {
const res = await adminFetch("/api/admin/import/clone/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceId: activeSource,
classnames: failed,
concurrency: 10,
}),
signal: abort.signal,
});
if (!res.ok || !res.body) {
toast.error(`Retry mislukt (${res.status})`);
return;
}
await readSseStream(
res.body,
(evt) => {
if (evt.type === "item_progress") {
const cn = String(evt.classname ?? "");
setBatchProgress((prev) => {
if (!prev) return prev;
const next = new Map(prev);
next.set(cn, {
classname: cn,
status: evt.status as BatchItemStatus["status"],
message: evt.message as string | undefined,
});
return next;
});
if (evt.status === "done" || evt.status === "failed") {
done++;
if (evt.status === "failed") {
setFailedClassnames((prev) =>
prev.includes(cn) ? prev : [...prev, cn],
);
}
}
}
if (evt.type === "batch_complete") {
okCount += Number(evt.succeeded ?? 0);
failCount += Number(evt.failed ?? 0);
}
},
abort.signal,
);
setBatchSucceeded(okCount);
setBatchFailed(failCount);
setBatchDone(true);
if (okCount > 0) {
toast.success(`${okCount} items opnieuw geïmporteerd`);
}
fetchItems(activeSearch, 1, activeSource);
fetchStats();
fetchCloneStats(activeSource);
} catch (err) {
if ((err as Error)?.name !== "AbortError") {
toast.error("Verbindingsfout tijdens retry");
}
} finally {
cloneAllAbortRef.current = null;
}
}
async function deleteItem() {
if (!deleting) return;
try {
@@ -690,8 +996,11 @@ export function StudioClient({
);
const notImportedCount = useMemo(
() => filteredItems.filter((i) => i.alreadyImported === false).length,
[filteredItems],
() =>
activeSource && cloneStats
? cloneStats.clonable
: filteredItems.filter((i) => i.alreadyImported === false).length,
[filteredItems, activeSource, cloneStats],
);
return (
@@ -748,7 +1057,7 @@ export function StudioClient({
</div>
<div className="flex flex-wrap items-center gap-1.5">
{stats && (
{stats && !activeSource && (
<>
<Badge variant="secondary" className="gap-1">
<Package size={11} /> {stats.totalInDb} in DB
@@ -767,6 +1076,26 @@ export function StudioClient({
</Badge>
</>
)}
{cloneStats && activeSource && (
<>
<Badge variant="secondary" className="gap-1">
<Package size={11} /> {cloneStats.total} total
</Badge>
<Badge variant="secondary" className="gap-1">
<Check size={11} className="text-[var(--admin-success)]" />{" "}
{cloneStats.present} aanwezig
</Badge>
{cloneStats.clonable > 0 && (
<Badge
variant="secondary"
className="gap-1 text-[var(--admin-accent)] border-[var(--admin-accent)]/40"
>
<CloudDownload size={11} /> {cloneStats.clonable} te
importeren
</Badge>
)}
</>
)}
</div>
<div className="flex items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 py-1">
@@ -848,11 +1177,17 @@ export function StudioClient({
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
All statuses {notImportedCount > 0 && notImportedCount}
All statuses{" "}
{notImportedCount > 0 && `(${notImportedCount} te importeren)`}
</SelectItem>
<SelectItem value="notImported">Not imported</SelectItem>
<SelectItem value="imported">Imported</SelectItem>
<SelectItem value="inCatalog">In catalog</SelectItem>
<SelectItem value="missingNitro">Missing nitro</SelectItem>
<SelectItem value="missingFurnidata">Missing furnidata</SelectItem>
<SelectItem value="missingCatalog">
Missing catalog entry
</SelectItem>
</SelectContent>
</Select>
@@ -1044,6 +1379,13 @@ export function StudioClient({
<div className="flex-1" />
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
<Button size="sm" className="gap-1.5" onClick={cloneAllMissing}>
<CloudDownload size={14} />
Alles importeren ({cloneStats.clonable})
</Button>
)}
{selectedNonImported > 0 && (
<Button
size="sm"
@@ -1162,17 +1504,42 @@ export function StudioClient({
{TYPE_LABELS[item.type] ?? item.type}
</Badge>
{item.alreadyImported ? (
<Badge
variant="secondary"
className={cn(
"h-4",
item.nitroExists
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
<>
{!item.nitroExists && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No nitro
</Badge>
)}
>
{item.nitroExists ? "Imported" : "No nitro"}
</Badge>
{item.hasFurnidata === false && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No furnidata
</Badge>
)}
{item.hasCatalogEntry === false && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No catalog
</Badge>
)}
{item.nitroExists &&
item.hasFurnidata !== false &&
item.hasCatalogEntry !== false && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-success)] border-[var(--admin-success)]/40"
>
Imported
</Badge>
)}
</>
) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
New
@@ -1286,17 +1653,42 @@ export function StudioClient({
</td>
<td className="px-2 py-1.5">
{item.alreadyImported ? (
<Badge
variant="secondary"
className={cn(
"h-4",
item.nitroExists
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
<div className="flex flex-wrap gap-0.5">
{!item.nitroExists && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No nitro
</Badge>
)}
>
{item.nitroExists ? "Imported" : "No nitro"}
</Badge>
{item.hasFurnidata === false && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No furnidata
</Badge>
)}
{item.hasCatalogEntry === false && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No catalog
</Badge>
)}
{item.nitroExists &&
item.hasFurnidata !== false &&
item.hasCatalogEntry !== false && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-success)] border-[var(--admin-success)]/40"
>
Imported
</Badge>
)}
</div>
) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
New
@@ -1401,14 +1793,20 @@ export function StudioClient({
done={batchDone}
succeeded={batchSucceeded}
failed={batchFailed}
cancelling={batchCancelling}
cancelling={batchCancelling || cloneAllCancelling}
verification={verification}
onCancel={cancelBatch}
startTime={batchStartTimeRef.current || undefined}
failedClassnames={
failedClassnames.length > 0 ? failedClassnames : undefined
}
onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch}
onDismiss={() => {
setBatchProgress(null);
setBatchDone(false);
setVerification(null);
setFailedClassnames([]);
}}
onRetry={activeSource ? retryFailed : undefined}
/>
) : null}
</main>
@@ -1586,6 +1984,8 @@ export function StudioClient({
onOpenChange={(open) => !open && setEditingClassname(null)}
/>
{confirmDialog}
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<DialogContent>
<DialogHeader>
@@ -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 },
});
});
});
+43 -18
View File
@@ -125,26 +125,51 @@ export async function fetchEmulatorRankForEdit(
if (rankRows.length === 0) return null;
const rankColumn = getRankPermissionColumn(rankId);
const permissionRows = await rawRows<
{
permission_key: string;
max_value: number;
value: number | string | null;
}[]
>(
db,
sql`
SELECT permission_key, max_value, ${sql.raw(quoteIdentifier(rankColumn))} AS value
FROM permission_definitions
ORDER BY permission_key ASC
`,
);
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);
const definitionColumns = await getTableColumns(db, "permission_definitions");
if (definitionColumns.has(rankColumn)) {
const permissionRows = await rawRows<
{
permission_key: string;
max_value: number;
value: number | string | null;
}[]
>(
db,
sql`
SELECT permission_key, max_value, ${sql.raw(quoteIdentifier(rankColumn))} AS value
FROM permission_definitions
ORDER BY permission_key ASC
`,
);
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];