4c3880e07f
Moderator role existed in the schema and was already respected by
comment deletion, but every admin page/route treated moderator
identically to a regular user (403/redirect). Wires it up narrowly:
admin/layout.tsx now lets admin+moderator through and filters the
nav by role, while every admin-only page (users, tiers, settings,
webhooks, insights, etc.) explicitly redirects moderators away via a
new requireFullAdminPage() helper -- the nav filter is UX, this is
the actual gate. Moderators land on Reports and Recipes: reports
GET/PATCH now accept requireAdmin({allowModerator: true}), and a new
PATCH /api/v1/admin/recipes/[id] lets admin+moderator unpublish a
public recipe (flip to private) as a takedown action, audit-logged.
Also found and fixed a real bug while auditing the PWA push pipeline
for a "push click-through" gap: public/sw.js had no `push` event
listener at all, so incoming push messages never displayed anything
-- push was silently non-functional end-to-end despite the
subscribe/send plumbing all working. Added the push listener
(showNotification) and a notificationclick listener that focuses an
existing tab or opens one at the payload's url.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
121 lines
5.7 KiB
TypeScript
121 lines
5.7 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { db } from "@epicure/db";
|
|
import { users, recipes, userUsage, recipePhotos, ratings, reports, cookingHistory, webhooks, apiKeys } from "@epicure/db";
|
|
import { count, eq, gte, sql } from "@epicure/db";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Users, BookOpen, Sparkles, Image, History, UserPlus, ChefHat, Flag, HardDrive, Webhook, KeyRound } from "lucide-react";
|
|
import { APP_VERSION, CHANGELOG } from "@/lib/changelog";
|
|
import { requireFullAdminPage } from "@/lib/require-admin-page";
|
|
|
|
export const metadata: Metadata = {};
|
|
|
|
export default async function AdminPage() {
|
|
await requireFullAdminPage();
|
|
const currentMonth = new Date().toISOString().slice(0, 7);
|
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
const [
|
|
userCount,
|
|
recipeCount,
|
|
proUserCount,
|
|
publicRecipeCount,
|
|
aiUsage,
|
|
imageCount,
|
|
storageUsage,
|
|
newUsers7d,
|
|
newRecipes7d,
|
|
cooked7d,
|
|
pendingReports,
|
|
webhookCount,
|
|
apiKeyCount,
|
|
] = await Promise.all([
|
|
db.select({ count: count() }).from(users).then((r) => r[0]),
|
|
db.select({ count: count() }).from(recipes).then((r) => r[0]),
|
|
db.select({ count: count() }).from(users).where(eq(users.tier, "pro")).then((r) => r[0]),
|
|
db.select({ count: count() }).from(recipes).where(eq(recipes.visibility, "public")).then((r) => r[0]),
|
|
db
|
|
.select({ total: sql<string>`coalesce(sum(${userUsage.aiCallsUsed}), 0)` })
|
|
.from(userUsage)
|
|
.where(eq(userUsage.month, currentMonth))
|
|
.then((r) => r[0]),
|
|
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
|
|
Promise.all([
|
|
db.select({ total: sql<string>`coalesce(sum(${recipePhotos.sizeMb}), 0)` }).from(recipePhotos).then((r) => r[0]),
|
|
db.select({ total: sql<string>`coalesce(sum(${ratings.photoSizeMb}), 0)` }).from(ratings).then((r) => r[0]),
|
|
db.select({ total: sql<string>`coalesce(sum(${users.avatarSizeMb}), 0)` }).from(users).then((r) => r[0]),
|
|
]).then(([photos, reviews, avatars]) => Number(photos?.total ?? 0) + Number(reviews?.total ?? 0) + Number(avatars?.total ?? 0)),
|
|
db.select({ count: count() }).from(users).where(gte(users.createdAt, sevenDaysAgo)).then((r) => r[0]),
|
|
db.select({ count: count() }).from(recipes).where(gte(recipes.createdAt, sevenDaysAgo)).then((r) => r[0]),
|
|
db.select({ count: count() }).from(cookingHistory).where(gte(cookingHistory.cookedAt, sevenDaysAgo)).then((r) => r[0]),
|
|
db.select({ count: count() }).from(reports).where(eq(reports.status, "pending")).then((r) => r[0]),
|
|
db.select({ count: count() }).from(webhooks).where(eq(webhooks.active, true)).then((r) => r[0]),
|
|
db.select({ count: count() }).from(apiKeys).then((r) => r[0]),
|
|
]);
|
|
|
|
const stats = [
|
|
{ label: "Total Users", value: userCount?.count ?? 0, sub: `${proUserCount?.count ?? 0} pro`, icon: Users },
|
|
{ label: "New Users (7d)", value: newUsers7d?.count ?? 0, icon: UserPlus },
|
|
{ label: "Total Recipes", value: recipeCount?.count ?? 0, sub: `${publicRecipeCount?.count ?? 0} public`, icon: BookOpen },
|
|
{ label: "New Recipes (7d)", value: newRecipes7d?.count ?? 0, icon: BookOpen },
|
|
{ label: "Cooked (7d)", value: cooked7d?.count ?? 0, icon: ChefHat },
|
|
{ label: "AI Calls This Month", value: Number(aiUsage?.total ?? 0), icon: Sparkles },
|
|
{ label: "Recipe Photos", value: imageCount?.count ?? 0, icon: Image },
|
|
{ label: "Total Storage", value: storageUsage, unit: "MB", icon: HardDrive },
|
|
{
|
|
label: "Pending Reports",
|
|
value: pendingReports?.count ?? 0,
|
|
icon: Flag,
|
|
href: "/admin/reports",
|
|
highlight: (pendingReports?.count ?? 0) > 0,
|
|
},
|
|
{ label: "Active Webhooks", value: webhookCount?.count ?? 0, icon: Webhook },
|
|
{ label: "API Keys Issued", value: apiKeyCount?.count ?? 0, icon: KeyRound },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h1 className="text-2xl font-bold tracking-tight">Overview</h1>
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{stats.map(({ label, value, sub, unit, icon: Icon, href, highlight }) => {
|
|
const card = (
|
|
<Card className={highlight ? "border-destructive/50" : undefined}>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
|
<Icon className={highlight ? "h-4 w-4 text-destructive" : "h-4 w-4 text-muted-foreground"} />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className={highlight ? "text-2xl font-bold text-destructive" : "text-2xl font-bold"}>
|
|
{value.toLocaleString()}{unit ? ` ${unit}` : ""}
|
|
</div>
|
|
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
return (
|
|
<div key={label}>
|
|
{href ? <Link href={href}>{card}</Link> : card}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Version</CardTitle>
|
|
<History className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">v{APP_VERSION}</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Released {CHANGELOG[0]?.date} ·{" "}
|
|
<Link href="/admin/changelog" className="underline hover:text-foreground">
|
|
View changelog
|
|
</Link>
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|