diff --git a/src/actions/admin-marketplace.ts b/src/actions/admin-marketplace.ts new file mode 100644 index 00000000..39036805 --- /dev/null +++ b/src/actions/admin-marketplace.ts @@ -0,0 +1,36 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requirePermissionRateLimited } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; +import { logStaffActivity } from "@/lib/services/staff-activity"; + +/** Cancel an active marketplace listing (state 1 → 0). */ +export async function cancelMarketplaceListing( + formData: FormData, +): Promise { + const staff = await requirePermissionRateLimited(PERMS.SHOP_EDIT); + const id = Number(formData.get("id")); + if (!(id > 0)) return; + + const listing = await prisma.marketplaceItems.findUnique({ + where: { id }, + select: { id: true, state: true, userId: true, itemId: true, price: true }, + }); + if (listing?.state !== 1) return; + + await prisma.marketplaceItems.update({ + where: { id }, + data: { state: 0 }, + }); + + await logStaffActivity({ + staffId: staff.id, + action: "marketplace_cancel", + description: `Cancelled marketplace listing #${id} (item ${listing.itemId}, user ${listing.userId}, price ${listing.price})`, + targetType: "marketplace", + targetId: id, + }); + revalidatePath("/admin/marketplace"); +} diff --git a/src/app/admin/analytics/activity/page.tsx b/src/app/admin/analytics/activity/page.tsx index 40bd94fd..cb0ff7a2 100644 --- a/src/app/admin/analytics/activity/page.tsx +++ b/src/app/admin/analytics/activity/page.tsx @@ -1,6 +1,6 @@ import { MessageSquare, Shield, Terminal, Users } from "lucide-react"; -import { getLocale, getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; +import { getLocale, getTranslations } from "next-intl/server"; import { StatsCard } from "@/components/admin/stats-card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; @@ -23,28 +23,35 @@ type ActivityData = { topChatters: { userId: number; username: string; messages: number }[]; }; -async function safeCount(fn: () => Promise): Promise { +async function safeCount( + fn: () => Promise, +): Promise<{ value: number; ok: boolean }> { try { - return await fn(); + return { value: await fn(), ok: true }; } catch { - return 0; + return { value: 0, ok: false }; } } -async function safeRawCount(sql: Promise<{ count: bigint }[]>): Promise { +async function safeRawCount( + sql: Promise<{ count: bigint }[]>, +): Promise<{ value: number; ok: boolean }> { try { const rows = await sql; - return Number(rows[0]?.count ?? 0); + return { value: Number(rows[0]?.count ?? 0), ok: true }; } catch { - return 0; + return { value: 0, ok: false }; } } -async function getActivityData(locale: string): Promise { +async function getActivityData( + locale: string, +): Promise { const now = Math.floor(Date.now() / 1000); const todayStart = now - (now % 86400); const weekAgo = now - 7 * 86400; const rangeStart = todayStart - 6 * 86400; + let degraded = false; const emptyDays: DayRow[] = []; for (let i = 6; i >= 0; i--) { @@ -99,7 +106,7 @@ async function getActivityData(locale: string): Promise { banMap = toMap(banRows); regMap = toMap(regRows); } catch { - /* keep empty maps */ + degraded = true; } const dailyData = emptyDays.map((day, idx) => { @@ -132,11 +139,13 @@ async function getActivityData(locale: string): Promise { ), ]); + for (const r of [weekChats, weekCommands, weekBans, weekRegistrations]) { + if (!r.ok) degraded = true; + } + let topChatters: ActivityData["topChatters"] = []; try { - const rows = await prisma.$queryRaw< - { userId: number; messages: bigint }[] - >` + const rows = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>` SELECT user_from_id AS userId, COUNT(*) AS messages FROM chatlogs_room WHERE timestamp >= ${weekAgo} GROUP BY user_from_id ORDER BY messages DESC LIMIT 10 @@ -160,15 +169,17 @@ async function getActivityData(locale: string): Promise { }); } catch { topChatters = []; + degraded = true; } return { dailyData, - weekChats, - weekCommands, - weekBans, - weekRegistrations, + weekChats: weekChats.value, + weekCommands: weekCommands.value, + weekBans: weekBans.value, + weekRegistrations: weekRegistrations.value, topChatters, + degraded, }; } @@ -180,10 +191,16 @@ export default async function ActivityPage() { const locale = await getLocale(); const t = await getTranslations("pages.admin.analytics.activity"); + const tRoot = await getTranslations("pages.admin.analytics"); const data = await getActivityData(locale); return (
+ {data.degraded ? ( +
+ {tRoot("loadError")} +
+ ) : null}
- {t("colDay")} + + {t("colDay")} + {t("colChats")} @@ -276,8 +295,7 @@ export default async function ActivityPage() { {i + 1} - {user.username || - t("userFallback", { id: user.userId })} + {user.username || t("userFallback", { id: user.userId })} {t("msgCount", { diff --git a/src/app/admin/analytics/economy/page.tsx b/src/app/admin/analytics/economy/page.tsx index 26d759d5..b025756f 100644 --- a/src/app/admin/analytics/economy/page.tsx +++ b/src/app/admin/analytics/economy/page.tsx @@ -1,6 +1,6 @@ import { ArrowLeftRight } from "lucide-react"; -import { getLocale, getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; +import { getLocale, getTranslations } from "next-intl/server"; import { RevenueChart } from "@/components/admin/revenue-chart"; import { StatsCard } from "@/components/admin/stats-card"; import { CurrencyIcon } from "@/components/shared/currency-icon"; @@ -42,17 +42,29 @@ async function safeCount(fn: () => Promise): Promise { } } -async function getEconomyData(locale: string): Promise { +async function getEconomyData( + locale: string, +): Promise<{ data: EconomyData; degraded: boolean }> { const now = Math.floor(Date.now() / 1000); const todayStart = now - (now % 86400); const weekAgo = now - 7 * 86400; const rangeStart = todayStart - 6 * 86400; + let degraded = false; try { const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([ - prisma.user.aggregate({ _sum: { credits: true } }).catch(() => null), - prisma.user.aggregate({ _sum: { pixels: true } }).catch(() => null), - prisma.user.aggregate({ _sum: { points: true } }).catch(() => null), + prisma.user.aggregate({ _sum: { credits: true } }).catch(() => { + degraded = true; + return null; + }), + prisma.user.aggregate({ _sum: { pixels: true } }).catch(() => { + degraded = true; + return null; + }), + prisma.user.aggregate({ _sum: { points: true } }).catch(() => { + degraded = true; + return null; + }), ]); const totalCredits = creditsAgg?._sum.credits ?? 0; const totalPixels = pixelsAgg?._sum.pixels ?? 0; @@ -72,24 +84,20 @@ async function getEconomyData(locale: string): Promise { shopCreditsSpent = shopRevenue._sum?.costCredits ?? 0; shopPointsSpent = shopRevenue._sum?.costPoints ?? 0; } catch { - /* shop logs table/columns may be missing */ + degraded = true; } - const [marketplaceActive, marketplaceSold, weekTrades] = await Promise.all( - [ - safeCount(() => - prisma.marketplaceItems.count({ where: { state: 1 } }), - ), - safeCount(() => - prisma.marketplaceItems.count({ - where: { state: 2, soldTimestamp: { gte: weekAgo } }, - }), - ), - safeCount(() => - prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }), - ), - ], - ); + const [marketplaceActive, marketplaceSold, weekTrades] = await Promise.all([ + safeCount(() => prisma.marketplaceItems.count({ where: { state: 1 } })), + safeCount(() => + prisma.marketplaceItems.count({ + where: { state: 2, soldTimestamp: { gte: weekAgo } }, + }), + ), + safeCount(() => + prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }), + ), + ]); const revenueData: EconomyData["revenueData"] = []; for (let i = 6; i >= 0; i--) { @@ -104,7 +112,11 @@ async function getEconomyData(locale: string): Promise { try { const revRows = await prisma.$queryRaw< - { day_bucket: bigint; credits: number | bigint; points: number | bigint }[] + { + day_bucket: bigint; + credits: number | bigint; + points: number | bigint; + }[] >` SELECT FLOOR(timestamp / 86400) AS day_bucket, COALESCE(SUM(cost_credits), 0) AS credits, @@ -128,23 +140,26 @@ async function getEconomyData(locale: string): Promise { } } } catch { - /* keep zero revenue series */ + degraded = true; } return { - totalCredits, - totalPixels, - totalPoints, - shopPurchases, - shopCreditsSpent, - shopPointsSpent, - marketplaceActive, - marketplaceSold, - weekTrades, - revenueData, + degraded, + data: { + totalCredits, + totalPixels, + totalPoints, + shopPurchases, + shopCreditsSpent, + shopPointsSpent, + marketplaceActive, + marketplaceSold, + weekTrades, + revenueData, + }, }; } catch { - return EMPTY; + return { data: EMPTY, degraded: true }; } } @@ -156,10 +171,16 @@ export default async function EconomyPage() { const locale = await getLocale(); const t = await getTranslations("pages.admin.analytics.economy"); - const data = await getEconomyData(locale); + const tRoot = await getTranslations("pages.admin.analytics"); + const { data, degraded } = await getEconomyData(locale); return (
+ {degraded ? ( +
+ {tRoot("loadError")} +
+ ) : null}
{data.shopPurchases}
- {t("creditsSpent")} + + {t("creditsSpent")} + {data.shopCreditsSpent.toLocaleString(locale)}
- {t("pointsSpent")} + + {t("pointsSpent")} + {data.shopPointsSpent.toLocaleString(locale)} diff --git a/src/app/admin/marketplace/page.tsx b/src/app/admin/marketplace/page.tsx new file mode 100644 index 00000000..79231ad4 --- /dev/null +++ b/src/app/admin/marketplace/page.tsx @@ -0,0 +1,171 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { cancelMarketplaceListing } from "@/actions/admin-marketplace"; +import { AdminSimplePager } from "@/components/admin/admin-simple-pager"; +import { StatusCard } from "@/components/admin/dashboard"; +import { calcPagination, parseListParams } from "@/lib/admin-helpers"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +function fmt(ts: number): string { + if (!ts) return "—"; + return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "); +} + +export default async function AdminMarketplacePage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.SHOP_VIEW, session.user.rank)) { + redirect("/admin"); + } + + const t = await getTranslations("pages.admin.marketplace"); + const canEdit = canAccess(permissions, PERMS.SHOP_EDIT, session.user.rank); + const raw = await searchParams; + const parsed = parseListParams(new URLSearchParams(raw)); + const filter = raw.filter === "sold" ? "sold" : "active"; + + const where = filter === "sold" ? { state: 2 } : { state: 1 }; + + let total = 0; + let rows: Awaited> = []; + let activeCount = 0; + let soldCount = 0; + let loadError = false; + + try { + [total, activeCount, soldCount] = await Promise.all([ + prisma.marketplaceItems.count({ where }), + prisma.marketplaceItems.count({ where: { state: 1 } }), + prisma.marketplaceItems.count({ where: { state: 2 } }), + ]); + const pagination = calcPagination(total, parsed.page, parsed.perPage); + rows = await prisma.marketplaceItems.findMany({ + where, + orderBy: { timestamp: "desc" }, + skip: pagination.offset, + take: pagination.perPage, + }); + } catch { + loadError = true; + } + + const pagination = calcPagination(total, parsed.page, parsed.perPage); + const userIds = [ + ...new Set(rows.map((r) => r.userId).filter((id) => id > 0)), + ]; + const users = + userIds.length > 0 + ? await prisma.user + .findMany({ + where: { id: { in: userIds } }, + select: { id: true, username: true }, + }) + .catch(() => []) + : []; + const usernameById = new Map(users.map((u) => [u.id, u.username])); + + return ( +
+ {loadError ? ( +
+ {t("loadError")} +
+ ) : null} + +
+ + {t("filterActive")} + + + {t("filterSold")} + +
+ +
+ + +
+ +
+

+ {filter === "sold" ? t("soldListings") : t("activeListings")} ({total} + ) +

+ {rows.length === 0 ? ( +
{t("empty")}
+ ) : ( +
+ + + + + + + + + {filter === "sold" ? : null} + {canEdit && filter === "active" ? ( + + ) : null} + + + + {rows.map((row) => ( + + + + + + + {filter === "sold" ? ( + + ) : null} + {canEdit && filter === "active" ? ( + + ) : null} + + ))} + +
{t("colId")}{t("colItem")}{t("colSeller")}{t("colPrice")}{t("colListed")}{t("colSold")}{t("colActions")}
{row.id} + {row.itemId} + + + {usernameById.get(row.userId) ?? `#${row.userId}`} + + {row.price}{fmt(row.timestamp)}{fmt(row.soldTimestamp)} +
+ + +
+
+
+ )} + +
+
+ ); +} diff --git a/src/components/admin/admin-simple-pager.tsx b/src/components/admin/admin-simple-pager.tsx index 66568486..cbd73d15 100644 --- a/src/components/admin/admin-simple-pager.tsx +++ b/src/components/admin/admin-simple-pager.tsx @@ -6,18 +6,21 @@ export function AdminSimplePager({ lastPage, total, basePath, + params, label, }: { page: number; lastPage: number; total: number; basePath: string; + /** Extra query params preserved across pages (e.g. filter=sold). */ + params?: Record; label?: string; }) { if (lastPage <= 1) return null; const href = (p: number) => { - const u = new URLSearchParams(); + const u = new URLSearchParams(params); u.set("page", String(p)); return `${basePath}?${u.toString()}`; }; diff --git a/src/lib/admin-nav.ts b/src/lib/admin-nav.ts index a4e0c2ed..a3d39527 100644 --- a/src/lib/admin-nav.ts +++ b/src/lib/admin-nav.ts @@ -457,6 +457,12 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [ icon: ShoppingCart, permission: PERMS.SHOP_VIEW, }, + { + href: "/admin/marketplace", + labelKey: "marketplace", + icon: Store, + permission: PERMS.SHOP_VIEW, + }, { href: "/admin/transactions", labelKey: "transactions", diff --git a/src/messages/en.json b/src/messages/en.json index 26a4b640..35a8254e 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -1041,6 +1041,7 @@ "hubShop": "Shop & payments", "catalog": "Catalog", "shop": "Shop", + "marketplace": "Marketplace", "transactions": "Transactions", "vouchers": "Vouchers", "subscriptions": "Subscriptions", @@ -1796,6 +1797,24 @@ "cancel": "Cancel" } }, + "marketplace": { + "title": "Marketplace", + "subtitle": "Hotel marketplace listings", + "activeListings": "Active listings", + "soldListings": "Sold", + "filterActive": "Active", + "filterSold": "Sold", + "empty": "No marketplace listings to show.", + "loadError": "Could not load marketplace data (database unavailable).", + "cancel": "Cancel listing", + "colId": "ID", + "colItem": "Item ID", + "colSeller": "Seller", + "colPrice": "Price", + "colListed": "Listed", + "colSold": "Sold at", + "colActions": "Actions" + }, "transactions": { "title": "Transactions", "subtitle": "PayPal payment log", diff --git a/src/messages/it.json b/src/messages/it.json index 66332b44..98004637 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -971,6 +971,7 @@ "economy": "Economia", "catalog": "Catalogo", "shop": "Negozio", + "marketplace": "Marketplace", "transactions": "Transazioni", "vouchers": "Buoni", "subscriptions": "Abbonamenti", @@ -1734,6 +1735,24 @@ "cancel": "Annulla" } }, + "marketplace": { + "title": "Marketplace", + "subtitle": "Annunci marketplace hotel", + "activeListings": "Annunci attivi", + "soldListings": "Venduti", + "filterActive": "Attivi", + "filterSold": "Venduti", + "empty": "Nessun annuncio marketplace da mostrare.", + "loadError": "Impossibile caricare i dati marketplace (database non disponibile).", + "cancel": "Annulla annuncio", + "colId": "ID", + "colItem": "Item ID", + "colSeller": "Venditore", + "colPrice": "Prezzo", + "colListed": "Pubblicato", + "colSold": "Venduto il", + "colActions": "Azioni" + }, "transactions": { "title": "Transazioni", "subtitle": "Registro pagamenti PayPal", diff --git a/src/messages/nl.json b/src/messages/nl.json index c6114850..d1122763 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -1016,6 +1016,7 @@ "economy": "Economie", "catalog": "Catalogus", "shop": "Winkel", + "marketplace": "Marketplace", "transactions": "Transacties", "vouchers": "Vouchers", "subscriptions": "Abonnementen", @@ -1710,6 +1711,24 @@ "cancel": "Annuleren" } }, + "marketplace": { + "title": "Marketplace", + "subtitle": "Hotel marketplace-aanbiedingen", + "activeListings": "Actieve listings", + "soldListings": "Verkocht", + "filterActive": "Actief", + "filterSold": "Verkocht", + "empty": "Geen marketplace-listings om te tonen.", + "loadError": "Kon marketplace-gegevens niet laden (database niet beschikbaar).", + "cancel": "Listing annuleren", + "colId": "ID", + "colItem": "Item-ID", + "colSeller": "Verkoper", + "colPrice": "Prijs", + "colListed": "Geplaatst", + "colSold": "Verkocht op", + "colActions": "Acties" + }, "transactions": { "title": "Transacties", "subtitle": "PayPal betalingslogboek",