diff --git a/src/actions/import-badges.ts b/src/actions/import-badges.ts index ebb07c0b..4d87c513 100644 --- a/src/actions/import-badges.ts +++ b/src/actions/import-badges.ts @@ -1,8 +1,8 @@ "use server"; import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteBadges } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; +import { importBadgeSynced } from "@/lib/services/import-badge"; export async function importBadgeFromRemote({ code, @@ -14,26 +14,9 @@ export async function importBadgeFromRemote({ description: string; }) { await requirePermission(PERMS.ASSETS_IMPORT); - try { - 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, - }, - }); - return { ok: true as const }; - } catch { - return { ok: false as const, error: "Failed to import badge" }; + const result = await importBadgeSynced({ code, name, description }); + if (!result.ok) { + return { ok: false as const, error: result.error }; } + return { ok: true as const }; } diff --git a/src/app/admin/ads/[id]/page.tsx b/src/app/admin/ads/[id]/page.tsx index 3a434f9b..29a89e88 100644 --- a/src/app/admin/ads/[id]/page.tsx +++ b/src/app/admin/ads/[id]/page.tsx @@ -7,7 +7,7 @@ import { updateAd } from "@/actions/admin-ads"; import { Button } from "@/components/ui/button"; import { db, WebsiteAds } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { EditAdDeleteButton } from "../ads-table"; +import { EditAdDeleteButton } from "../edit-ad-delete-button"; export default async function EditAd({ params, diff --git a/src/app/admin/ads/ads-table.tsx b/src/app/admin/ads/ads-table.tsx index 3f621d23..67eb61a7 100644 --- a/src/app/admin/ads/ads-table.tsx +++ b/src/app/admin/ads/ads-table.tsx @@ -84,38 +84,3 @@ export function AdsTable({ ads }: { ads: AdRow[] }) { ); } - -export function EditAdDeleteButton({ id }: { id: string }) { - const t = useTranslations("pages.admin.ads"); - const { run, isPending } = useServerAction(); - const { confirm, dialog: confirmDialog } = useConfirmDialog(); - - async function handleDelete() { - const ok = await confirm({ - title: t("delete"), - description: t("confirmDelete"), - confirmLabel: t("delete"), - cancelLabel: t("form.cancel"), - }); - if (!ok) return; - run(() => deleteAd({ id }), { - successMessage: t("deleteSuccess"), - errorMessage: t("deleteError"), - redirectTo: "/admin/ads", - }); - } - - return ( - <> - {confirmDialog} - - - ); -} diff --git a/src/app/admin/ads/edit-ad-delete-button.tsx b/src/app/admin/ads/edit-ad-delete-button.tsx new file mode 100644 index 00000000..15fd7c92 --- /dev/null +++ b/src/app/admin/ads/edit-ad-delete-button.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { deleteAd } from "@/actions/admin-ads"; +import { useConfirmDialog } from "@/components/admin/confirm-dialog"; +import { Button } from "@/components/ui/button"; +import { useServerAction } from "@/hooks/use-server-action"; + +export function EditAdDeleteButton({ id }: { id: string }) { + const t = useTranslations("pages.admin.ads"); + const { run, isPending } = useServerAction(); + const { confirm, dialog: confirmDialog } = useConfirmDialog(); + + async function handleDelete() { + const ok = await confirm({ + title: t("delete"), + description: t("confirmDelete"), + confirmLabel: t("delete"), + cancelLabel: t("form.cancel"), + }); + if (!ok) return; + run(() => deleteAd({ id }), { + successMessage: t("deleteSuccess"), + errorMessage: t("deleteError"), + redirectTo: "/admin/ads", + }); + } + + return ( + <> + {confirmDialog} + + + ); +} diff --git a/src/app/admin/bans/page.tsx b/src/app/admin/bans/page.tsx index 3f2ac45c..5730f7bf 100644 --- a/src/app/admin/bans/page.tsx +++ b/src/app/admin/bans/page.tsx @@ -1,5 +1,5 @@ import { desc, eq, gt, or } from "drizzle-orm"; -import { Ban } from "lucide-react"; +import { Ban as BanIcon } from "lucide-react"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { createBan, liftBan } from "@/actions/admin-bans"; @@ -7,7 +7,7 @@ import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { StatusCard } from "@/components/admin/dashboard"; import { Button } from "@/components/ui/button"; import { unixNow } from "@/lib/bans"; -import { Ban as BanTable, db } from "@/lib/db"; +import { Ban, db } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; @@ -19,13 +19,13 @@ export default async function AdminBans() { const t = await getTranslations("pages.admin.bans"); const now = unixNow(); - let bans: (typeof BanTable.$inferSelect)[] = []; + let bans: (typeof Ban.$inferSelect)[] = []; try { bans = await db .select() - .from(BanTable) - .where(or(eq(BanTable.banExpire, 0), gt(BanTable.banExpire, now))) - .orderBy(desc(BanTable.timestamp)) + .from(Ban) + .where(or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now))) + .orderBy(desc(Ban.timestamp)) .limit(100); } catch { bans = []; @@ -35,7 +35,7 @@ export default async function AdminBans() { const accountBans = bans.filter((b) => b.type === "account").length; return ( - +
{!summary && ( - + {loading && ( + )} - {loading ? progress || "Running…" : "Run Full Catalog Audit"} - +
)} {loading && checkProgress && ( diff --git a/src/app/admin/import/badges/import-badges-client.tsx b/src/app/admin/import/badges/import-badges-client.tsx index 8dc5fbbe..7978234e 100644 --- a/src/app/admin/import/badges/import-badges-client.tsx +++ b/src/app/admin/import/badges/import-badges-client.tsx @@ -15,7 +15,6 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { importBadgeFromRemote } from "@/actions/import-badges"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -25,6 +24,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { adminFetch } from "@/lib/admin-fetch"; import { cn } from "@/lib/utils"; import { BadgeEditorDialog } from "./badge-editor-dialog"; @@ -119,14 +119,32 @@ export function ImportBadgesClient() { fetchBadges(activeSearch, newOffset, allHotels); } - async function doImport(badge: BadgeItem) { - setImportingCode(badge.code); - try { - const result = await importBadgeFromRemote({ + async function postImportBadge(badge: BadgeItem) { + const res = await adminFetch("/api/admin/import/badges", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: badge.code, name: badge.name, description: badge.description, - }); + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + return { + ok: false as const, + error: + (data as { error?: string }).error || + `Failed to import ${badge.code}`, + }; + } + return { ok: true as const }; + } + + async function doImport(badge: BadgeItem) { + setImportingCode(badge.code); + try { + const result = await postImportBadge(badge); if (result.ok) { toast.success(`${badge.code} imported`); setBadges((prev) => @@ -140,7 +158,7 @@ export function ImportBadgesClient() { return n; }); } else { - toast.error(result.error || `Failed to import ${badge.code}`); + toast.error(result.error); } } catch { toast.error(`Network error importing ${badge.code}`); @@ -159,11 +177,7 @@ export function ImportBadgesClient() { fail = 0; for (const badge of toImport) { try { - const result = await importBadgeFromRemote({ - code: badge.code, - name: badge.name, - description: badge.description, - }); + const result = await postImportBadge(badge); if (result.ok) { ok++; setBadges((prev) => diff --git a/src/app/admin/import/furni/import-furni-client.tsx b/src/app/admin/import/furni/import-furni-client.tsx index f5e55a8b..edc871f1 100644 --- a/src/app/admin/import/furni/import-furni-client.tsx +++ b/src/app/admin/import/furni/import-furni-client.tsx @@ -209,6 +209,7 @@ export function ImportFurniClient() { // Reorganize catalog const [reorganizing, setReorganizing] = useState(false); const searchRef = useRef(null); + const batchAbortRef = useRef(null); // Confirmation dialogs const [confirmBatch, setConfirmBatch] = useState(false); @@ -484,6 +485,9 @@ export function ImportFurniClient() { } setBatchProgress(new Map(initialProgress)); + const abort = new AbortController(); + batchAbortRef.current = abort; + try { const res = await adminFetch("/api/admin/import/furni/batch", { method: "POST", @@ -500,6 +504,7 @@ export function ImportFurniClient() { })), concurrency: 3, }), + signal: abort.signal, }); if (!res.ok || !res.body) { @@ -561,10 +566,17 @@ export function ImportFurniClient() { } } } catch (err) { - console.error("[Import] Batch import error:", err); - toast.error("Connection lost during batch import. Check results above."); + if ((err as Error).name === "AbortError") { + toast.info("Batch import cancelled"); + } else { + console.error("[Import] Batch import error:", err); + toast.error( + "Connection lost during batch import. Check results above.", + ); + } } finally { setBatchImporting(false); + batchAbortRef.current = null; setSelected(new Set()); fetchStats(); } @@ -1105,6 +1117,14 @@ export function ImportFurniClient() { {progressStats.failed} failed )} +
{Array.from(batchProgress.values()).map((item) => ( @@ -1425,6 +1445,16 @@ export function ImportFurniClient() { } )} + {batchImporting && ( + + )} {hasImported && (
@@ -304,6 +342,12 @@ export function UploadFurniClient() { {result.catalogItemId}
)} + {result.sqlFile ? ( +
+ SQL file: + {result.sqlFile} +
+ ) : null} {result.warnings.length > 0 && (
diff --git a/src/app/admin/import/repair-icons/repair-icons-client.tsx b/src/app/admin/import/repair-icons/repair-icons-client.tsx index a0a3bd3d..23e6a327 100644 --- a/src/app/admin/import/repair-icons/repair-icons-client.tsx +++ b/src/app/admin/import/repair-icons/repair-icons-client.tsx @@ -1,7 +1,7 @@ "use client"; import { Check, ImageIcon, Loader2, Wrench } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { adminFetch } from "@/lib/admin-fetch"; @@ -31,6 +31,7 @@ export function RepairIconsClient() { failed: number; skipped: number; } | null>(null); + const abortRef = useRef(null); const startRepair = useCallback(async () => { setRunning(true); @@ -38,9 +39,13 @@ export function RepairIconsClient() { setLog([]); setResult(null); + const abort = new AbortController(); + abortRef.current = abort; + try { const res = await adminFetch("/api/admin/import/furni/repair-icons", { method: "POST", + signal: abort.signal, }); if (!res.body) { @@ -131,26 +136,42 @@ export function RepairIconsClient() { setResult({ succeeded, failed, skipped }); if (succeeded > 0) toast.success(`${succeeded} icons downloaded`); if (failed > 0) toast.error(`${failed} icons could not be obtained`); - } catch { - toast.error("Repair request failed"); + } catch (err) { + if ((err as Error).name === "AbortError") { + toast.info("Repair cancelled"); + } else { + toast.error("Repair request failed"); + } } finally { setRunning(false); + abortRef.current = null; } }, []); return (
{!result && ( - + {running && ( + )} - {running - ? `Repairing… ${progress ? `${progress.done}/${progress.total}` : ""}` - : "Start Icon Repair"} - +
)} {progress && ( diff --git a/src/app/admin/import/sync/sync-all-client.tsx b/src/app/admin/import/sync/sync-all-client.tsx index 4c546615..cf551c0e 100644 --- a/src/app/admin/import/sync/sync-all-client.tsx +++ b/src/app/admin/import/sync/sync-all-client.tsx @@ -1,7 +1,7 @@ "use client"; import { Check, CloudDownload, Loader2 } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { adminFetch } from "@/lib/admin-fetch"; @@ -26,6 +26,7 @@ export function SyncAllClient() { failed: number; sources: number; } | null>(null); + const abortRef = useRef(null); const startSync = useCallback(async () => { setSyncing(true); @@ -33,11 +34,15 @@ export function SyncAllClient() { setLog([]); setResult(null); + const abort = new AbortController(); + abortRef.current = abort; + try { const res = await adminFetch("/api/admin/import/clone/sync-all", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), + signal: abort.signal, }); if (!res.body) { @@ -117,26 +122,42 @@ export function SyncAllClient() { setResult({ succeeded, failed, sources: 1 }); if (succeeded > 0) toast.success(`${succeeded} items synced`); if (failed > 0) toast.error(`${failed} items failed`); - } catch { - toast.error("Sync request 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 (
{!result && ( - + {syncing && ( + )} - {syncing - ? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}` - : "Start Sync All Sources"} - +
)} {progress && ( diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 4a8bc3bd..d1999b96 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,7 +2,7 @@ import { count, desc, eq, gt, inArray, or } from "drizzle-orm"; import { Activity, ArrowRight, - Ban, + Ban as BanIcon, Database, Newspaper, Users, @@ -11,13 +11,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { StatusCard } from "@/components/admin/dashboard"; import { unixNow } from "@/lib/bans"; -import { - Ban as BanTable, - db, - StaffActivities, - User, - WebsiteArticles, -} from "@/lib/db"; +import { Ban, db, StaffActivities, User, WebsiteArticles } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; type RankCount = { rank: number; count: number }; @@ -36,8 +30,8 @@ export default async function AdminDashboard() { db.select({ total: count() }).from(WebsiteArticles), db .select({ total: count() }) - .from(BanTable) - .where(or(eq(BanTable.banExpire, 0), gt(BanTable.banExpire, now))), + .from(Ban) + .where(or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now))), ]); users = usersRow[0]?.total ?? 0; online = onlineRow[0]?.total ?? 0; @@ -119,7 +113,7 @@ export default async function AdminDashboard() { label={t("activeBans")} value={dbOk ? bans : "—"} state={bans > 0 ? "warn" : "neutral"} - icon={} + icon={} /> { @@ -61,31 +62,14 @@ export const POST = withAdmin( if (!code || !name) return apiError("Missing code or name", 400); - try { - const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8"); - const texts = JSON.parse(raw); + const result = await importBadgeSynced({ + code, + name, + description: description || "", + }); - const nameKey = `badge_name_${code}`; - const descKey = `badge_desc_${code}`; - - if (texts[nameKey] !== undefined) { - return apiError("Badge already exists in ExternalTexts.json", 409); - } - - texts[nameKey] = name; - texts[descKey] = description || ""; - - await fs.writeFile( - EXTERNAL_TEXTS_PATH, - JSON.stringify(texts, null, 4), - "utf-8", - ); - } catch (err) { - console.warn( - "[import-badges] Failed to update ExternalTexts.json:", - (err as Error).message, - ); - return apiError("Failed to update ExternalTexts.json", 500); + if (!result.ok) { + return apiError(result.error, result.status); } logAudit({ diff --git a/src/components/admin/dashboard.tsx b/src/components/admin/dashboard.tsx index 608a9f9e..ba180c35 100644 --- a/src/components/admin/dashboard.tsx +++ b/src/components/admin/dashboard.tsx @@ -1,17 +1,8 @@ -"use client"; - -import Image from "next/image"; import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; -export interface OnlineUser { - id: number; - username: string; - look: string; - roomId: number | null; - roomName: string | null; - lastActivity: Date; -} +export type { OnlineUser } from "@/components/admin/online-users-widget"; +export { OnlineUsersWidget } from "@/components/admin/online-users-widget"; type State = "ok" | "warn" | "danger" | "neutral"; @@ -32,7 +23,7 @@ const STATE_ICON_BG: Record = { "from-[var(--admin-accent)]/15 to-[var(--admin-accent)]/5 text-[var(--admin-accent)]", }; -/** A single live-status tile (online users, emulator, DB, …). */ +/** A single live-status tile (online users, emulator, DB, …). Server-safe. */ export function StatusCard({ label, value, @@ -138,89 +129,3 @@ export function InfoItem({
); } - -/** Live list of online users with room and activity tracking */ -export function OnlineUsersWidget({ - users, - isLoading = false, -}: { - users: OnlineUser[]; - isLoading?: boolean; -}) { - const formatLastSeen = (date: Date) => { - const now = new Date(); - const diff = Math.floor((now.getTime() - date.getTime()) / 1000); - - if (diff < 60) return "Just now"; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - return `${Math.floor(diff / 86400)}d ago`; - }; - - return ( -
-
-

- Online Now -

- - {isLoading ? "..." : users.length} users - -
- -
- {users.length === 0 ? ( -
- - No users online - -
- ) : ( - users.map((user) => ( -
-
- {user.username} - {user.roomId && user.roomName && ( -
- -
- )} -
- -
- -
- {user.roomId && user.roomName ? ( - - 🏠 {user.roomName} - - ) : ( - - ⏱️ {formatLastSeen(user.lastActivity)} - - )} -
-
-
- )) - )} -
-
- ); -} diff --git a/src/components/admin/online-users-widget.tsx b/src/components/admin/online-users-widget.tsx new file mode 100644 index 00000000..2b593f7a --- /dev/null +++ b/src/components/admin/online-users-widget.tsx @@ -0,0 +1,98 @@ +"use client"; + +import Image from "next/image"; + +export interface OnlineUser { + id: number; + username: string; + look: string; + roomId: number | null; + roomName: string | null; + lastActivity: Date; +} + +/** Live list of online users with room and activity tracking */ +export function OnlineUsersWidget({ + users, + isLoading = false, +}: { + users: OnlineUser[]; + isLoading?: boolean; +}) { + const formatLastSeen = (date: Date) => { + const now = new Date(); + const diff = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diff < 60) return "Just now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; + }; + + return ( +
+
+

+ Online Now +

+ + {isLoading ? "..." : users.length} users + +
+ +
+ {users.length === 0 ? ( +
+ + No users online + +
+ ) : ( + users.map((user) => ( +
+
+ {user.username} + {user.roomId && user.roomName && ( +
+ +
+ )} +
+ +
+ +
+ {user.roomId && user.roomName ? ( + + 🏠 {user.roomName} + + ) : ( + + ⏱️ {formatLastSeen(user.lastActivity)} + + )} +
+
+
+ )) + )} +
+
+ ); +} diff --git a/src/lib/admin-nav.ts b/src/lib/admin-nav.ts index 56c4bfc9..4b7c70bb 100644 --- a/src/lib/admin-nav.ts +++ b/src/lib/admin-nav.ts @@ -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. */ diff --git a/src/lib/import-backend-contract.test.ts b/src/lib/import-backend-contract.test.ts index 22acfaa3..6aaf9672 100644 --- a/src/lib/import-backend-contract.test.ts +++ b/src/lib/import-backend-contract.test.ts @@ -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"); + }); }); diff --git a/src/lib/services/import-badge.ts b/src/lib/services/import-badge.ts new file mode 100644 index 00000000..e83cb203 --- /dev/null +++ b/src/lib/services/import-badge.ts @@ -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 { + try { + const raw = await fs.readFile(EXTERNAL_TEXTS_PATH, "utf-8"); + const texts = JSON.parse(raw) as Record; + + 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 { + 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 { + 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 }; +} diff --git a/src/lib/staff-smoke-contract.test.ts b/src/lib/staff-smoke-contract.test.ts index 03c58822..14a826a9 100644 --- a/src/lib/staff-smoke-contract.test.ts +++ b/src/lib/staff-smoke-contract.test.ts @@ -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"); + }); }); diff --git a/src/messages/en.json b/src/messages/en.json index 756f6a09..10b6d756 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2597,6 +2597,17 @@ "commando": "Commando", "rooms": "Rooms", "import": "Import", + "importOverview": "Overview", + "importBadges": "Badges", + "importFurni": "Furni", + "importUpload": "Upload", + "importClothing": "Clothing", + "importEffects": "Effects", + "importPets": "Pets", + "importClone": "Clone", + "importSync": "Sync", + "importRepairIcons": "Repair icons", + "importAudit": "Audit", "translations": "Translations", "logs": "Logs", "audit": "Audit", @@ -2620,7 +2631,9 @@ "analytics": "Analytics", "analyticsSubtitle": "Hotel activity, retention and economy", "devops": "DevOps", - "devopsSubtitle": "Service health and emulator errors" + "devopsSubtitle": "Service health and emulator errors", + "import": "Import", + "importSubtitle": "Furni, badges, clothing and asset tools" }, "wordfilter": { "title": "Word Filter", diff --git a/src/messages/it.json b/src/messages/it.json index edb6cdef..b86f40a9 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -2801,6 +2801,17 @@ "commando": "Commando", "rooms": "Stanze", "import": "Import", + "importOverview": "Panoramica", + "importBadges": "Badge", + "importFurni": "Furni", + "importUpload": "Upload", + "importClothing": "Abbigliamento", + "importEffects": "Effetti", + "importPets": "Pet", + "importClone": "Clone", + "importSync": "Sync", + "importRepairIcons": "Ripara icone", + "importAudit": "Audit", "translations": "Traduzioni", "logs": "Log", "audit": "Audit", @@ -2825,7 +2836,9 @@ "analytics": "Analitiche", "analyticsSubtitle": "Attività hotel, retention ed economia", "devops": "DevOps", - "devopsSubtitle": "Salute servizi ed errori emulator" + "devopsSubtitle": "Salute servizi ed errori emulator", + "import": "Import", + "importSubtitle": "Furni, badge, abbigliamento e strumenti asset" }, "settings": { "title": "Impostazioni CMS",