feat: admin requests tab + 30s disk cache + sync all pending
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import axios from "axios";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { io } from "../index";
|
import { io } from "../index";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,19 @@ import { backfillUserHistory } from "../services/tautulli";
|
|||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
|
|
||||||
const router = Router();
|
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(
|
router.get(
|
||||||
"/settings",
|
"/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 };
|
export { router as adminRouter };
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ let cachedDiskSpace: {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
async function fetchDiskSpace() {
|
async function fetchDiskSpace() {
|
||||||
if (cachedDiskSpace && Date.now() - cachedDiskSpace.fetchedAt < 5 * 60 * 1000)
|
if (cachedDiskSpace && Date.now() - cachedDiskSpace.fetchedAt < 30 * 1000)
|
||||||
return cachedDiskSpace;
|
return cachedDiskSpace;
|
||||||
try {
|
try {
|
||||||
const radarrRes = await overseerClient.get("/settings/radarr");
|
const radarrRes = await overseerClient.get("/settings/radarr");
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Coffee,
|
Coffee,
|
||||||
Database,
|
Database,
|
||||||
|
Film,
|
||||||
Gift,
|
Gift,
|
||||||
|
List,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
@@ -81,6 +83,7 @@ export default function AdminPage() {
|
|||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [settings, setSettings] = useState<SystemSettings | null>(null);
|
const [settings, setSettings] = useState<SystemSettings | null>(null);
|
||||||
const [kofiPayments, setKofiPayments] = useState<KofiPayment[]>([]);
|
const [kofiPayments, setKofiPayments] = useState<KofiPayment[]>([]);
|
||||||
|
const [requests, setRequests] = useState<any[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||||||
@@ -96,16 +99,19 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [analyticsRes, usersRes, settingsRes, kofiRes] = await Promise.all([
|
const [analyticsRes, usersRes, settingsRes, kofiRes, requestsRes] =
|
||||||
adminApi.getAnalytics(),
|
await Promise.all([
|
||||||
adminApi.getUsers(),
|
adminApi.getAnalytics(),
|
||||||
adminApi.getSettings(),
|
adminApi.getUsers(),
|
||||||
adminApi.getKofiPayments(),
|
adminApi.getSettings(),
|
||||||
]);
|
adminApi.getKofiPayments(),
|
||||||
|
adminApi.getAllRequests(),
|
||||||
|
]);
|
||||||
setAnalytics(analyticsRes.data);
|
setAnalytics(analyticsRes.data);
|
||||||
setUsers(usersRes.data.users || []);
|
setUsers(usersRes.data.users || []);
|
||||||
setSettings(settingsRes.data);
|
setSettings(settingsRes.data);
|
||||||
setKofiPayments(kofiRes.data.payments || []);
|
setKofiPayments(kofiRes.data.payments || []);
|
||||||
|
setRequests(requestsRes.data.requests || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Admin load error:", err);
|
console.error("Admin load error:", err);
|
||||||
toast.error("Failed to load coop secrets");
|
toast.error("Failed to load coop secrets");
|
||||||
@@ -201,7 +207,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="flock" className="w-full">
|
<Tabs defaultValue="flock" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-4 gap-2 rounded-full border-4 border-foreground bg-muted/30 p-2 mb-8 h-auto">
|
<TabsList className="grid w-full grid-cols-5 gap-2 rounded-full border-4 border-foreground bg-muted/30 p-2 mb-8 h-auto">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="flock"
|
value="flock"
|
||||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||||
@@ -220,6 +226,12 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
<Coffee className="mr-2 h-4 w-4" /> DONATIONS
|
<Coffee className="mr-2 h-4 w-4" /> DONATIONS
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="requests"
|
||||||
|
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||||
|
>
|
||||||
|
<Film className="mr-2 h-4 w-4" /> REQUESTS
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="stats"
|
value="stats"
|
||||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||||
@@ -502,8 +514,65 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
||||||
|
<TabsContent value="requests" className="focus-visible:outline-none">
|
||||||
|
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h2 className="font-display text-3xl uppercase italic">The Feed Queue</h2>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
toast.info("Checking with Overseer...");
|
||||||
|
try {
|
||||||
|
const res = await adminApi.syncAllRequests();
|
||||||
|
toast.success(`Synced ${res.data.updated?.length || 0} requests`);
|
||||||
|
loadData();
|
||||||
|
} catch {
|
||||||
|
toast.error("Sync failed");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full border-2 border-primary font-bold"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="mr-2 h-4 w-4" /> SYNC
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<div className="text-center py-10 opacity-40">
|
||||||
|
<div className="text-6xl mb-4">📭</div>
|
||||||
|
<div className="font-black uppercase tracking-widest text-sm">No requests in the queue.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{requests.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-2xl border-4 border-foreground bg-accent/5 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-black text-lg">{r.title}</span>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${r.status === "APPROVED" ? "bg-primary text-white" : r.status === "DECLINED" ? "bg-destructive text-white" : "bg-secondary text-secondary-foreground"}`}>
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-muted-foreground mt-1">
|
||||||
|
{r.user?.plexUsername || "Unknown"} • {r.mediaType === "movie" ? "Movie" : "TV"} • {formatNumber(r.creditsCost)} $COOP
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] font-bold text-muted-foreground uppercase">
|
||||||
|
{new Date(r.requestedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="stats" className="focus-visible:outline-none">
|
<TabsContent value="stats" className="focus-visible:outline-none">
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-5">
|
||||||
<div className="rounded-3xl border-4 border-foreground bg-card p-6 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)]">
|
<div className="rounded-3xl border-4 border-foreground bg-card p-6 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)]">
|
||||||
<div className="text-xs font-black uppercase tracking-widest text-muted-foreground mb-2 flex items-center gap-2">
|
<div className="text-xs font-black uppercase tracking-widest text-muted-foreground mb-2 flex items-center gap-2">
|
||||||
<Users className="h-3 w-3" /> Flock Size
|
<Users className="h-3 w-3" /> Flock Size
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const adminApi = {
|
|||||||
getSystemStats: () => api.get("/transactions/admin/stats"),
|
getSystemStats: () => api.get("/transactions/admin/stats"),
|
||||||
backfillUser: (userId: string) => api.post(`/admin/users/${userId}/backfill`),
|
backfillUser: (userId: string) => api.post(`/admin/users/${userId}/backfill`),
|
||||||
getKofiPayments: () => api.get("/admin/kofi-payments"),
|
getKofiPayments: () => api.get("/admin/kofi-payments"),
|
||||||
|
getAllRequests: () => api.get("/admin/requests"),
|
||||||
|
syncAllRequests: () => api.post("/admin/sync-requests"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Overseer API
|
// Overseer API
|
||||||
|
|||||||
Reference in New Issue
Block a user