Fix TypeScript errors and implement furniture import ID integrity with 18+ age verification
CI / check (push) Successful in 29s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m2s

- Add termsAccepted and ageVerified columns to User table
- Update register schema with new boolean fields
- Fix register form age verification checkbox (th -> t)
- Fix furni-import spriteId declaration order
- Fix batch route variable naming (id -> spriteId)
- Fix catalog-audit import path and ensure correct types
- Hardened import with per-item id conflict checks
- Added audit option for FurnitureData.json spriteId conflicts
- Updated tagline to include Leeftijdsvereiste: 18+
This commit is contained in:
openhands committed 2026-08-14 16:37:00 +02:00
1 parent e5ec3c1f06
commit e76c530e4f
13 files changed
+267 -104

No files matched your search

-2
View File
@@ -52,8 +52,6 @@ const nextConfig: NextConfig = {
ignoreBuildErrors: false,
},
// Enable React Compiler for automatic memoization
// Compress responses with gzip/brotli
compress: true,
+5 -5
View File
@@ -7,7 +7,7 @@
},
"packageManager": "[email protected]",
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "biome check .",
@@ -30,8 +30,8 @@
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@hookform/resolvers": "5.7.1",
"@next/bundle-analyzer": "16.3.0",
"@hookform/resolvers": "5.8.0",
"@next/bundle-analyzer": "16.3.1",
"@tanstack/react-virtual": "3.14.9",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -50,7 +50,7 @@
"motion": "13.1.0",
"music-metadata": "11.14.0",
"mysql2": "3.23.3",
"next": "16.3.0",
"next": "16.3.1",
"next-auth": "5.0.0-beta.32",
"next-intl": "4.13.6",
"otplib": "13.4.1",
@@ -58,7 +58,7 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "7.85.0",
"resend": "6.19.0",
"resend": "6.20.0",
"server-only": "0.0.1",
"sharp": "0.35.3",
"sonner": "2.0.8",
+70 -69
View File
File diff suppressed because it is too large. Load diff
-26
View File
@@ -7,7 +7,6 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteAds } from "@/lib/db";
import { formPositiveBigInt } from "@/lib/form-data";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
@@ -117,28 +116,3 @@ export const deleteAd = adminAction(
return actionOk();
},
);
/** Legacy form POST delete — kept for compatibility; prefer client deleteAd action. */
export async function deleteAdForm(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = formPositiveBigInt(formData, "id");
if (!id) return;
try {
await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
await logStaffActivity({
staffId: staff.id,
action: "ad_delete",
description: `Deleted advertisement #${id}`,
targetType: "website_ad",
targetId: Number(id),
});
} catch (err) {
logger.error("Action failed: deleteAdForm", {
action: "deleteAdForm",
id: Number(id),
error: err instanceof Error ? err.message : "DB error",
});
}
redirect("/admin/ads");
}
@@ -155,6 +155,7 @@ export function AuditClient() {
const [applySql, setApplySql] = useState(false);
const [repairFurniData, setRepairFurniData] = useState(false);
const [repairStructure, setRepairStructure] = useState(false);
const [checkFurniDataIds, setCheckFurniDataIds] = useState(false);
const [issues, setIssues] = useState<AuditIssue[]>([]);
const [missingFromSources, setMissingFromSources] = useState<
@@ -421,6 +422,13 @@ export function AuditClient() {
>
Repair FurnitureData.json (add missing + dedupe)
</AuditCheckbox>
<AuditCheckbox
checked={checkFurniDataIds}
disabled={loading}
onChange={setCheckFurniDataIds}
>
Check FurnitureData.json for spriteId conflicts
</AuditCheckbox>
<AuditCheckbox
checked={repairStructure}
disabled={loading}
+4
View File
@@ -18,6 +18,7 @@ export const POST = withAdmin(
let applySql = false;
let repairFurniData = false;
let repairStructure = false;
let checkFurniDataIds = false;
try {
const body = (await request.json().catch(() => ({}))) as {
repair?: unknown;
@@ -26,6 +27,7 @@ export const POST = withAdmin(
applySql?: unknown;
repairFurniData?: unknown;
repairStructure?: unknown;
checkFurniDataIds?: unknown;
};
repair = body.repair === true;
repairNitros = body.repairNitros === true;
@@ -33,6 +35,7 @@ export const POST = withAdmin(
applySql = body.applySql === true;
repairFurniData = body.repairFurniData === true;
repairStructure = body.repairStructure === true;
checkFurniDataIds = body.checkFurniDataIds === true;
} catch (err) {
logger.warn("Failed to parse audit request body", { err });
}
@@ -55,6 +58,7 @@ export const POST = withAdmin(
applySql,
repairFurniData,
repairStructure,
checkFurniDataIds,
});
} catch (err) {
send({
+19 -1
View File
@@ -42,6 +42,7 @@ export function RegisterForm({
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
const [serverError, formAction, isPending] = useActionState(register, null);
const [termsAccepted, setTermsAccepted] = useState(false);
const [ageVerified, setAgeVerified] = useState(false);
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
@@ -280,7 +281,7 @@ export function RegisterForm({
</div>
</div>
{/* Terms */}
{/* Terms & Age Verification */}
<div
className="rounded-xl p-4 flex flex-col gap-3"
style={{
@@ -314,6 +315,23 @@ export function RegisterForm({
{t("termsAccept", { hotel: hotelName })}
</label>
</div>
<div className="flex items-center gap-3 text-sm">
<input
type="checkbox"
id="ageVerified"
name="ageVerified"
checked={ageVerified}
onChange={(e) => setAgeVerified(e.target.checked)}
className="w-5 h-5 rounded border-2 accent-(--color-primary) shrink-0 cursor-pointer"
/>
<label
htmlFor="ageVerified"
className="font-semibold cursor-pointer select-none"
style={{ color: "var(--color-text-readable)" }}
>
{t("ageVerified", { hotel: hotelName })}
</label>
</div>
</div>
{/* Captcha */}
+2
View File
@@ -76,6 +76,8 @@ export const User = mysqlTable("users", {
homeRoom: int("home_room").notNull().default(0),
secretKey: varchar("secret_key", { length: 40 }),
pincode: varchar("pincode", { length: 11 }),
termsAccepted: boolean("terms_accepted").notNull().default(false),
ageVerified: boolean("age_verified").notNull().default(false),
extraRank: int("extra_rank"),
twoFactorSecret: text("two_factor_secret"),
twoFactorRecoveryCodes: text("two_factor_recovery_codes"),
+58
View File
@@ -16,6 +16,7 @@ import {
repairFurniData,
repairOrphanedCatalog,
} from "./catalog-repair";
import { readFurniData } from "./furni-data";
import { ensureDirectories } from "./furni-import";
import {
assetNameCandidates,
@@ -118,6 +119,7 @@ export interface AuditSummary {
duplicatesMerged: number;
duplicateRowsRemoved: number;
remappedReferences: number;
furniDataIdConflicts: number;
}
export interface CatalogAuditOptions {
@@ -128,6 +130,7 @@ export interface CatalogAuditOptions {
organizeSql?: boolean;
repairFurniData?: boolean;
repairStructure?: boolean;
checkFurniDataIds?: boolean;
}
export async function runCatalogAudit(
@@ -722,6 +725,60 @@ export async function runCatalogAudit(
}
}
// ── FurnitureData.json id conflict check ──────────────
let furniDataIdConflicts: {
classname: string;
itemId: number;
conflictingId: number;
existingClassname: string;
}[] = [];
if (options?.checkFurniDataIds) {
onEvent?.({
type: "progress",
message: "Checking FurnitureData.json for spriteId conflicts…",
});
try {
const furniData = (await readFurniData()) as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>;
// Build map of id → classname from both sections
const idToClassname = new Map<number, string>();
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const e of furniData[section]?.furnitype ?? []) {
const id = Number(e?.id);
const classname = typeof e?.classname === "string" ? e.classname : "";
if (!Number.isFinite(id) || id <= 0 || !classname) continue;
const existing = idToClassname.get(id);
if (existing && existing !== classname) {
// Conflict: same id used by different classname
furniDataIdConflicts.push({
classname,
itemId: id,
conflictingId: id,
existingClassname: existing,
});
} else if (!existing) {
idToClassname.set(id, classname);
}
}
}
// Remove duplicates (same classname + id pair counted once)
const seen = new Set<string>();
furniDataIdConflicts = furniDataIdConflicts.filter((c) => {
const key = `${c.classname}:${c.conflictingId}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
} catch (err) {
onEvent?.({
type: "error",
message: `FurnitureData id check failed: ${(err as Error).message}`,
});
}
}
onEvent?.({ type: "progress", message: "Comparing with clone sources…" });
const sources = await listSources();
@@ -811,6 +868,7 @@ export async function runCatalogAudit(
duplicatesMerged,
duplicateRowsRemoved,
remappedReferences,
furniDataIdConflicts: furniDataIdConflicts.length,
};
onEvent?.({
File diff suppressed because it is too large. Load diff
+1
View File
@@ -11,6 +11,7 @@ vi.mock("@/lib/services/furni-data", () => ({
appendFurniEntry: vi.fn(),
buildFurniEntry: vi.fn(),
removeFurniEntry: vi.fn(),
findFurniDataIdConflict: vi.fn(),
}));
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
+11
View File
@@ -14,6 +14,7 @@ import {
import {
appendFurniEntry,
buildFurniEntry,
findFurniDataIdConflict,
removeFurniEntry,
} from "@/lib/services/furni-data";
import {
@@ -472,6 +473,16 @@ export async function importSingleFurni(params: {
};
}
// ── Check if spriteId already used in FurnitureData.json ──
const takenBy = await findFurniDataIdConflict(originalId, classname);
if (takenBy) {
return {
ok: false,
warnings,
error: `spriteId ${originalId} already used by "${takenBy}" in FurnitureData.json`,
};
}
const itemType = type === "wallitem" ? "i" : "s";
const spriteId = originalId;
+2 -1
View File
@@ -29,7 +29,7 @@
"header": {
"online": "{count} {hotel} online",
"nitroClient": "Nitro client",
"tagline": "Een online virtuele wereld waar je je eigen avatar kunt maken, vrienden kunt maken, kunt chatten, kamers kunt maken en nog veel meer!",
"tagline": "Een online virtuele wereld waar je je eigen avatar kunt maken, vrienden kunt maken, kunt chatten, kamers kunt maken en nog veel meer! Leeftijdsvereiste: 18+",
"login": "Inloggen",
"or": "Of",
"createAccount": "Account aanmaken"
@@ -919,6 +919,7 @@
"passwordError": "Wachtwoord moet minimaal 6 tekens bevatten.",
"confirmPasswordError": "Wachtwoorden komen niet overeen.",
"termsRequired": "Je moet de voorwaarden & regels accepteren.",
"ageVerified": "Ik ben 18+ en ga akkoord met de leeftijdsvereiste.",
"usernameRequired": "Gebruikersnaam is verplicht.",
"emailRequired": "E-mail is verplicht.",
"passwordRequired": "Wachtwoord is verplicht.",