Files
Epicure/apps/web/app/admin/storage/page.tsx
T
Arnaud c8f4b50ef3 rename: "Team" billing tier to "Family"
All literal "team" tier-value references renamed to "family" across
API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum
value itself is renamed in place via ALTER TYPE ... RENAME VALUE
(migration 0044) rather than drizzle-kit's auto-generated
drop-and-recreate-the-enum migration, which would have failed against
any existing row still holding 'team' — RENAME VALUE preserves
existing data with no cast/backfill needed.

Also adds STRIPE_PLAN.md — a full Stripe billing integration plan
(Checkout+Portal, tier→Price mapping, admin billing dashboard, and a
multi-user Family-group design since Family is meant to cover several
accounts under one subscription, not one payer). Planning only, no
Stripe code yet.

v0.47.0
2026-07-18 00:25:51 +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 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>
);
}