f6975e98a9
Recipe count and storage usage shared the monthly user_usage bucket with AI calls, so both incorrectly reset every month even though nothing was deleted. Only AI calls should be monthly. Recipe count and storage are now derived live from real data (recipes, recipe/review photos, avatar) instead of a counter — deleting a photo or recipe is itself the "decrement", no extra wiring needed. Storage size is tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb) and threaded through presign -> upload -> save. Also fixes avatar removal silently no-oping (client sent a field the PATCH schema didn't recognize). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
119 lines
5.6 KiB
TypeScript
119 lines
5.6 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";
|
|
|
|
export const metadata: Metadata = {};
|
|
|
|
export default async function AdminPage() {
|
|
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>
|
|
);
|
|
}
|