Files
Arnaud 4c3880e07f feat: moderator-scoped admin access + fix push notifications not displaying (v0.66.0)
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>
2026-07-21 23:31:47 +02:00

145 lines
5.7 KiB
TypeScript

import type { Metadata } from "next";
import { db, users, recipePhotos, recipes, eq, desc, sql, count } from "@epicure/db";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { HardDrive } from "lucide-react";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminStoragePage() {
await requireFullAdminPage();
const [totalPhotos, recipesWithPhotos, photosByTier, topUsers] = await Promise.all([
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
db
.select({ count: count() })
.from(recipePhotos)
.where(eq(recipePhotos.isCover, true))
.then((r) => r[0]),
db
.select({
tier: users.tier,
photoCount: sql<string>`count(${recipePhotos.id})`,
})
.from(recipePhotos)
.innerJoin(recipes, eq(recipePhotos.recipeId, recipes.id))
.innerJoin(users, eq(recipes.authorId, users.id))
.groupBy(users.tier),
db
.select({
userId: users.id,
name: users.name,
email: users.email,
tier: users.tier,
photoCount: sql<string>`count(${recipePhotos.id})`,
})
.from(recipePhotos)
.innerJoin(recipes, eq(recipePhotos.recipeId, recipes.id))
.innerJoin(users, eq(recipes.authorId, users.id))
.groupBy(users.id, users.name, users.email, users.tier)
.orderBy(desc(sql`count(${recipePhotos.id})`))
.limit(10),
]);
const freePhotos = Number(photosByTier.find((r) => r.tier === "free")?.photoCount ?? 0);
const proPhotos = Number(photosByTier.find((r) => r.tier === "pro")?.photoCount ?? 0);
const familyPhotos = Number(photosByTier.find((r) => r.tier === "family")?.photoCount ?? 0);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Storage Usage</h1>
<p className="text-muted-foreground text-sm mt-1">
Photo storage breakdown by tier and user.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Photos</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{(totalPhotos?.count ?? 0).toLocaleString()}</div>
<p className="text-xs text-muted-foreground mt-1">{recipesWithPhotos?.count ?? 0} cover photos</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Free Tier Photos</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{freePhotos.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Pro Tier Photos</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{proPhotos.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Family Tier Photos</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{familyPhotos.toLocaleString()}</div>
</CardContent>
</Card>
</div>
<div>
<h2 className="text-lg font-semibold mb-3">Top 10 Users by Photo Count</h2>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium">User</th>
<th className="px-4 py-3 text-left font-medium">Tier</th>
<th className="px-4 py-3 text-right font-medium">Photos</th>
</tr>
</thead>
<tbody>
{topUsers.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-muted-foreground">
No photos uploaded yet.
</td>
</tr>
) : (
topUsers.map((row) => (
<tr key={row.userId} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<div className="font-medium">{row.name ?? "Unknown"}</div>
{row.email && (
<div className="text-xs text-muted-foreground">{row.email}</div>
)}
</td>
<td className="px-4 py-3">
<Badge variant={row.tier === "pro" ? "default" : "secondary"}>
{row.tier ?? "—"}
</Badge>
</td>
<td className="px-4 py-3 text-right font-mono">
{Number(row.photoCount).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}