feat(admin): server-safe StatusCard and Import hub polish
Split OnlineUsersWidget from StatusCard, decouple ad delete button, sync badge import to ExternalTexts+WebsiteBadges, add Import section hub with cancelable SSE jobs and upload SQL option. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
db957d7fb1
commit
9c4949186c
21 files changed
+576
-249
No files matched your search
@@ -34,6 +34,7 @@ import {
|
||||
Terminal,
|
||||
Ticket,
|
||||
Trophy,
|
||||
Upload,
|
||||
Users,
|
||||
UsersRound,
|
||||
Volume2,
|
||||
@@ -217,6 +218,38 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
||||
{ href: "/admin/devops/errors", labelKey: "errors" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "import",
|
||||
titleKey: "import",
|
||||
subtitleKey: "importSubtitle",
|
||||
icon: Upload,
|
||||
prefixes: ["/admin/import"],
|
||||
tabs: [
|
||||
{
|
||||
href: "/admin/import",
|
||||
labelKey: "importOverview",
|
||||
match: ["/admin/import"],
|
||||
},
|
||||
{ href: "/admin/import/badges", labelKey: "importBadges" },
|
||||
{
|
||||
href: "/admin/import/furni/upload",
|
||||
labelKey: "importUpload",
|
||||
match: ["/admin/import/furni/upload"],
|
||||
},
|
||||
{
|
||||
href: "/admin/import/furni",
|
||||
labelKey: "importFurni",
|
||||
match: ["/admin/import/furni"],
|
||||
},
|
||||
{ href: "/admin/import/clothing", labelKey: "importClothing" },
|
||||
{ href: "/admin/import/effects", labelKey: "importEffects" },
|
||||
{ href: "/admin/import/pets", labelKey: "importPets" },
|
||||
{ href: "/admin/import/clone", labelKey: "importClone" },
|
||||
{ href: "/admin/import/sync", labelKey: "importSync" },
|
||||
{ href: "/admin/import/repair-icons", labelKey: "importRepairIcons" },
|
||||
{ href: "/admin/import/audit", labelKey: "importAudit" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Longest-prefix match so nested routes (e.g. /admin/logs/audit) win. */
|
||||
|
||||
@@ -2,11 +2,13 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROUTES = [
|
||||
"audit",
|
||||
"badges",
|
||||
"badges/edit",
|
||||
"clone",
|
||||
"clone/batch",
|
||||
"clone/icon",
|
||||
"clone/sync-all",
|
||||
"clothing",
|
||||
"clothing/batch",
|
||||
"clothing/sets",
|
||||
@@ -17,7 +19,9 @@ const ROUTES = [
|
||||
"furni/batch",
|
||||
"furni/batch-regen",
|
||||
"furni/nitro-editor",
|
||||
"furni/repair-icons",
|
||||
"furni/resync",
|
||||
"furni/upload",
|
||||
"pets",
|
||||
"pets/batch",
|
||||
"pets/icon",
|
||||
@@ -35,4 +39,17 @@ describe("admin import backend contract", () => {
|
||||
const source = readFileSync("src/actions/import-furni.ts", "utf8");
|
||||
expect(source).not.toContain("deleted: 0, remaining: 0");
|
||||
});
|
||||
|
||||
it("keeps badge import ExternalTexts + WebsiteBadges in sync", () => {
|
||||
const service = readFileSync("src/lib/services/import-badge.ts", "utf8");
|
||||
expect(service).toContain("ExternalTexts.json");
|
||||
expect(service).toContain("WebsiteBadges");
|
||||
expect(service).toContain("importBadgeSynced");
|
||||
expect(readFileSync("src/actions/import-badges.ts", "utf8")).toContain(
|
||||
"importBadgeSynced",
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/app/api/admin/import/badges/route.ts", "utf8"),
|
||||
).toContain("importBadgeSynced");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { db, WebsiteBadges } from "@/lib/db";
|
||||
|
||||
const EXTERNAL_TEXTS_PATH = path.join(
|
||||
process.cwd(),
|
||||
"public/nitro-assets/gamedata/ExternalTexts.json",
|
||||
);
|
||||
|
||||
export type ImportBadgeResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string; status: number };
|
||||
|
||||
/** Write badge name/desc into ExternalTexts.json (409 if already present). */
|
||||
export async function writeBadgeToExternalTexts(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
): Promise<ImportBadgeResult> {
|
||||
try {
|
||||
const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8");
|
||||
const texts = JSON.parse(raw) as Record<string, string>;
|
||||
|
||||
const nameKey = `badge_name_${code}`;
|
||||
const descKey = `badge_desc_${code}`;
|
||||
|
||||
if (texts[nameKey] !== undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Badge already exists in ExternalTexts.json",
|
||||
status: 409,
|
||||
};
|
||||
}
|
||||
|
||||
texts[nameKey] = name;
|
||||
texts[descKey] = description || "";
|
||||
|
||||
await fs.writeFile(
|
||||
EXTERNAL_TEXTS_PATH,
|
||||
JSON.stringify(texts, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[import-badges] Failed to update ExternalTexts.json:",
|
||||
(err as Error).message,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: "Failed to update ExternalTexts.json",
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Upsert WebsiteBadges row so CMS badge tables stay in sync. */
|
||||
export async function upsertWebsiteBadge(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(WebsiteBadges)
|
||||
.values({
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single import path: ExternalTexts.json + WebsiteBadges.
|
||||
* Callers should gate with ASSETS_IMPORT.
|
||||
*/
|
||||
export async function importBadgeSynced(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<ImportBadgeResult> {
|
||||
const texts = await writeBadgeToExternalTexts(
|
||||
input.code,
|
||||
input.name,
|
||||
input.description,
|
||||
);
|
||||
if (!texts.ok) return texts;
|
||||
|
||||
try {
|
||||
await upsertWebsiteBadge(input.code, input.name, input.description);
|
||||
} catch {
|
||||
return { ok: false, error: "Failed to import badge", status: 500 };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -179,6 +179,21 @@ describe("staff smoke contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps StatusCard server-safe without client boundary", () => {
|
||||
expect(
|
||||
readFileSync("src/components/admin/dashboard.tsx", "utf8"),
|
||||
).not.toMatch(/^["']use client["']/m);
|
||||
expect(existsSync("src/components/admin/online-users-widget.tsx")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
readFileSync("src/components/admin/online-users-widget.tsx", "utf8"),
|
||||
).toContain("use client");
|
||||
expect(existsSync("src/app/admin/ads/edit-ad-delete-button.tsx")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ships ops health alerts, optional DB backup, and health rate limit", () => {
|
||||
const worker = readFileSync("scripts/jobs-worker.ts", "utf8");
|
||||
expect(worker).toContain("checkOpsHealth");
|
||||
@@ -215,4 +230,19 @@ describe("staff smoke contract", () => {
|
||||
"/admin/items",
|
||||
);
|
||||
});
|
||||
|
||||
it("ships import hub with synced badge import path", () => {
|
||||
const nav = readFileSync("src/lib/admin-nav.ts", "utf8");
|
||||
expect(nav).toContain('id: "import"');
|
||||
expect(nav).toContain("importSubtitle");
|
||||
expect(nav).toContain("/admin/import");
|
||||
expect(existsSync("src/lib/services/import-badge.ts")).toBe(true);
|
||||
const badgeService = readFileSync(
|
||||
"src/lib/services/import-badge.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(badgeService).toContain("ExternalTexts.json");
|
||||
expect(badgeService).toContain("WebsiteBadges");
|
||||
expect(badgeService).toContain("importBadgeSynced");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user