diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index a6889e5..6fcd814 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,3 +1,4 @@ +import axios from "axios"; import { Router } from "express"; import { io } from "../index"; import { @@ -10,6 +11,19 @@ import { backfillUserHistory } from "../services/tautulli"; import { prisma } from "../utils/prisma"; const router = Router(); +const OVERSEER_URL = process.env.OVERSEER_URL || ""; +const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || ""; +const overseerClient = axios.create({ + baseURL: `${OVERSEER_URL}/api/v1`, + headers: { "X-Api-Key": OVERSEER_API_KEY }, +}); + +const mapOverseerStatus = (status: any) => { + const s = Number(status); + if (s >= 5) return "APPROVED"; + if (s === 4) return "DECLINED"; + return "PENDING"; +}; router.get( "/settings", @@ -247,4 +261,74 @@ router.get( }), ); +router.get( + "/requests", + authenticate, + requireAdmin, + asyncHandler(async (_req: AuthenticatedRequest, res) => { + const requests = await prisma.contentRequest.findMany({ + orderBy: { requestedAt: "desc" }, + take: 100, + include: { + user: { + select: { plexUsername: true, email: true }, + }, + }, + }); + res.json({ requests }); + }), +); + +router.post( + "/sync-requests", + authenticate, + requireAdmin, + asyncHandler(async (_req: AuthenticatedRequest, res) => { + const pending = await prisma.contentRequest.findMany({ + where: { status: "PENDING" }, + }); + const updated: any[] = []; + for (const request of pending) { + try { + const response = await overseerClient.get( + `/request/${request.overseerRequestId}`, + ); + const mapped = mapOverseerStatus(response.data?.status); + if (mapped !== "PENDING") { + const result = await prisma.contentRequest.update({ + where: { id: request.id }, + data: { status: mapped as any }, + }); + if (mapped === "DECLINED") { + await prisma.transaction.create({ + data: { + userId: request.userId, + type: "ADJUSTMENT", + amount: request.creditsCost, + description: `Refunded: ${request.title}`, + contentTitle: request.title, + }, + }); + await prisma.user.update({ + where: { id: request.userId }, + data: { totalSpent: { decrement: request.creditsCost } }, + }); + } + io.to(`user:${request.userId}`).emit("request_updated", { + id: request.id, + status: result.status, + title: request.title, + }); + updated.push({ + id: request.id, + status: result.status, + title: request.title, + }); + } + } catch {} + } + res.json({ success: true, updated, checked: pending.length }); + }), +); + export { router as adminRouter }; diff --git a/backend/src/routes/overseer.ts b/backend/src/routes/overseer.ts index 898a916..47d1bd9 100644 --- a/backend/src/routes/overseer.ts +++ b/backend/src/routes/overseer.ts @@ -23,7 +23,7 @@ let cachedDiskSpace: { } | null = null; async function fetchDiskSpace() { - if (cachedDiskSpace && Date.now() - cachedDiskSpace.fetchedAt < 5 * 60 * 1000) + if (cachedDiskSpace && Date.now() - cachedDiskSpace.fetchedAt < 30 * 1000) return cachedDiskSpace; try { const radarrRes = await overseerClient.get("/settings/radarr"); diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 4dc252e..d1f495d 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -4,7 +4,9 @@ import { ArrowLeft, Coffee, Database, + Film, Gift, + List, RefreshCcw, Save, Search, @@ -81,6 +83,7 @@ export default function AdminPage() { const [users, setUsers] = useState([]); const [settings, setSettings] = useState(null); const [kofiPayments, setKofiPayments] = useState([]); + const [requests, setRequests] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [isLoading, setIsLoading] = useState(true); const [isSavingSettings, setIsSavingSettings] = useState(false); @@ -96,16 +99,19 @@ export default function AdminPage() { const loadData = async () => { try { - const [analyticsRes, usersRes, settingsRes, kofiRes] = await Promise.all([ - adminApi.getAnalytics(), - adminApi.getUsers(), - adminApi.getSettings(), - adminApi.getKofiPayments(), - ]); + const [analyticsRes, usersRes, settingsRes, kofiRes, requestsRes] = + await Promise.all([ + adminApi.getAnalytics(), + adminApi.getUsers(), + adminApi.getSettings(), + adminApi.getKofiPayments(), + adminApi.getAllRequests(), + ]); setAnalytics(analyticsRes.data); setUsers(usersRes.data.users || []); setSettings(settingsRes.data); setKofiPayments(kofiRes.data.payments || []); + setRequests(requestsRes.data.requests || []); } catch (err) { console.error("Admin load error:", err); toast.error("Failed to load coop secrets"); @@ -201,7 +207,7 @@ export default function AdminPage() { - + DONATIONS + + REQUESTS + + + +
+
+

The Feed Queue

+ +
+ + {requests.length === 0 ? ( +
+
📭
+
No requests in the queue.
+
+ ) : ( +
+ {requests.map((r) => ( +
+
+
+ {r.title} + + {r.status} + +
+
+ {r.user?.plexUsername || "Unknown"} • {r.mediaType === "movie" ? "Movie" : "TV"} • {formatNumber(r.creditsCost)} $COOP +
+
+
+
+ {new Date(r.requestedAt).toLocaleDateString()} +
+
+
+ ))} +
+ )} +
+
-
+
Flock Size diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0c9c427..d8f1138 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -95,6 +95,8 @@ export const adminApi = { getSystemStats: () => api.get("/transactions/admin/stats"), backfillUser: (userId: string) => api.post(`/admin/users/${userId}/backfill`), getKofiPayments: () => api.get("/admin/kofi-payments"), + getAllRequests: () => api.get("/admin/requests"), + syncAllRequests: () => api.post("/admin/sync-requests"), }; // Overseer API