Files
Epicure/apps/web/app/admin/users/[id]/page.tsx
T
Arnaud f6975e98a9 fix: recipe/storage tier limits are lifetime totals, not monthly (v0.58.0)
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>
2026-07-20 20:32:31 +02:00

174 lines
6.1 KiB
TypeScript

import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { db, users, recipes, userUsage, eq, desc, count, and } from "@epicure/db";
import { getStorageUsedMb } from "@/lib/tiers";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { UserEditor } from "@/components/admin/user-editor";
import { ResetUsageButton } from "@/components/admin/reset-usage-button";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export const metadata: Metadata = {};
interface PageProps {
params: Promise<{ id: string }>;
}
const ROLE_COLORS = {
user: "secondary",
moderator: "outline",
admin: "destructive",
} as const;
const TIER_COLORS = {
free: "secondary",
pro: "default",
family: "default",
} as const;
export default async function AdminUserDetailPage({ params }: PageProps) {
const { id } = await params;
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
if (!user) notFound();
const currentMonth = new Date().toISOString().slice(0, 7);
const [usage] = await db
.select()
.from(userUsage)
.where(and(eq(userUsage.userId, id), eq(userUsage.month, currentMonth)))
.limit(1);
const [[recipeCountRow], storageUsedMb] = await Promise.all([
db.select({ count: count() }).from(recipes).where(eq(recipes.authorId, id)),
getStorageUsedMb(id),
]);
const recentRecipes = await db
.select({
id: recipes.id,
title: recipes.title,
visibility: recipes.visibility,
createdAt: recipes.createdAt,
})
.from(recipes)
.where(eq(recipes.authorId, id))
.orderBy(desc(recipes.createdAt))
.limit(10);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link
href="/admin/users"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to Users
</Link>
</div>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={user.avatarUrl ?? ""} alt={user.name} />
<AvatarFallback className="text-lg">
{user.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-2xl font-bold tracking-tight">{user.name}</h1>
<p className="text-muted-foreground">{user.email}</p>
<p className="text-xs text-muted-foreground mt-1">
Joined {user.createdAt.toLocaleDateString()}
</p>
</div>
<div className="ml-auto flex gap-2">
<Badge variant={ROLE_COLORS[user.role]}>{user.role}</Badge>
<Badge variant={TIER_COLORS[user.tier]}>{user.tier}</Badge>
</div>
</div>
<Separator />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Edit Role &amp; Tier</CardTitle>
</CardHeader>
<CardContent>
<UserEditor userId={user.id} currentRole={user.role} currentTier={user.tier} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Usage &amp; Limits</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">AI Calls Used ({currentMonth})</span>
<span className="font-medium">{usage?.aiCallsUsed ?? 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Recipes</span>
<span className="font-medium">{recipeCountRow?.count ?? 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Storage Used</span>
<span className="font-medium">{storageUsedMb} MB</span>
</div>
<div className="pt-2">
<ResetUsageButton userId={user.id} />
</div>
</CardContent>
</Card>
</div>
<div>
<h2 className="text-lg font-semibold mb-3">Recent Recipes</h2>
{recentRecipes.length === 0 ? (
<p className="text-muted-foreground text-sm">No recipes yet.</p>
) : (
<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">Title</th>
<th className="px-4 py-3 text-left font-medium">Visibility</th>
<th className="px-4 py-3 text-left font-medium">Created</th>
<th className="px-4 py-3 text-left font-medium">Link</th>
</tr>
</thead>
<tbody>
{recentRecipes.map((recipe) => (
<tr key={recipe.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{recipe.title}</td>
<td className="px-4 py-3">
<Badge variant="outline">{recipe.visibility}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{recipe.createdAt.toLocaleDateString()}
</td>
<td className="px-4 py-3">
<Link
href={`/r/${recipe.id}`}
className="text-primary underline-offset-4 hover:underline text-xs"
>
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}