Files
Epicure/apps/web/app/admin/storage/page.tsx
T
Arnaud c31ab8771a feat: add Team billing tier
Widens the tier enum from free/pro to free/pro/team and every
"free" | "pro" cast that assumed exactly two tiers (~30 call sites:
every AI route's withAiQuota/checkAndIncrementTierLimit call, admin
user/invite management, upload quota checks, OpenAPI schemas). Team
sits above Pro with genuinely unlimited recipes/public-recipes (the
-1 sentinel, which Pro doesn't actually use — Pro uses large finite
numbers instead) and a higher AI-call/storage cap. Seeded via
db:seed, editable afterward from Admin > Tiers.

role (user/moderator/admin — permissions) and tier (free/pro/team —
billing limits) stay separate concepts, as they already were; this
does not touch role-based permissions.

Requires migration 0043 to run against a live DB — not applied in
this sandbox (no Docker here); run `pnpm db:migrate` then `pnpm db:seed`.

v0.44.0
2026-07-17 17:34:13 +02:00

143 lines
5.6 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";
export const metadata: Metadata = {};
export default async function AdminStoragePage() {
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 teamPhotos = Number(photosByTier.find((r) => r.tier === "team")?.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">Team Tier Photos</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{teamPhotos.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>
);
}