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>
98 lines
3.6 KiB
TypeScript
98 lines
3.6 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { headers } from "next/headers";
|
|
import { auth } from "@/lib/auth/server";
|
|
import { db, userAiKeys, userModelPrefs, users, tierDefinitions, userUsage, eq, and } from "@epicure/db";
|
|
import { UNLIMITED, getRecipeCount, getStorageUsedMb } from "@/lib/tiers";
|
|
import { ByokManager } from "@/components/settings/byok-manager";
|
|
import { ModelPrefsForm } from "@/components/settings/model-prefs-form";
|
|
import { UsageQuotaSection } from "@/components/settings/usage-quota-section";
|
|
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
|
|
|
export const metadata: Metadata = {};
|
|
|
|
export default async function AiSettingsPage() {
|
|
const session = await auth.api.getSession({ headers: await headers() });
|
|
if (!session) return null;
|
|
const m = getMessages((session.user as { locale?: string }).locale);
|
|
|
|
const currentMonth = new Date().toISOString().slice(0, 7);
|
|
|
|
const [aiKeys, modelPrefs, dbUser] = await Promise.all([
|
|
db.query.userAiKeys.findMany({
|
|
where: eq(userAiKeys.userId, session.user.id),
|
|
columns: { provider: true },
|
|
}),
|
|
db.query.userModelPrefs.findFirst({
|
|
where: eq(userModelPrefs.userId, session.user.id),
|
|
}),
|
|
// Tier comes from the DB, not the (up to 5-minute-stale) session cookie
|
|
// cache, so a just-changed tier's limits show up immediately here.
|
|
db.query.users.findFirst({ where: eq(users.id, session.user.id), columns: { tier: true } }),
|
|
]);
|
|
|
|
const [tierDef, usage, recipeCount, storageUsedMb] = await Promise.all([
|
|
db.query.tierDefinitions.findFirst({ where: eq(tierDefinitions.tier, dbUser?.tier ?? "free") }),
|
|
db.query.userUsage.findFirst({ where: and(eq(userUsage.userId, session.user.id), eq(userUsage.month, currentMonth)) }),
|
|
getRecipeCount(session.user.id),
|
|
getStorageUsedMb(session.user.id),
|
|
]);
|
|
|
|
const asLimit = (n: number | undefined) => (n === undefined || n === UNLIMITED ? null : n);
|
|
|
|
const usageMetrics = [
|
|
{
|
|
label: m.settings.usage.aiCalls,
|
|
used: usage?.aiCallsUsed ?? 0,
|
|
limit: asLimit(tierDef?.aiCallsPerMonth),
|
|
unlimitedLabel: m.settings.usage.unlimited,
|
|
},
|
|
{
|
|
label: m.settings.usage.recipes,
|
|
used: recipeCount,
|
|
limit: asLimit(tierDef?.maxRecipes),
|
|
unlimitedLabel: m.settings.usage.unlimited,
|
|
},
|
|
{
|
|
label: m.settings.usage.storage,
|
|
used: storageUsedMb,
|
|
limit: asLimit(tierDef?.storageMb),
|
|
unit: " MB",
|
|
unlimitedLabel: m.settings.usage.unlimited,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<section className="rounded-xl border p-6 space-y-4">
|
|
<div>
|
|
<h2 className="font-semibold text-lg">{m.settings.usage.title}</h2>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{formatMessage(m.settings.usage.description, { month: currentMonth })}
|
|
</p>
|
|
</div>
|
|
<UsageQuotaSection metrics={usageMetrics} />
|
|
</section>
|
|
|
|
<section className="rounded-xl border p-6 space-y-4">
|
|
<div>
|
|
<h2 className="font-semibold text-lg">{m.settings.byok.title}</h2>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{m.settings.byok.description}
|
|
</p>
|
|
</div>
|
|
<ByokManager initialKeys={aiKeys.map((k) => k.provider)} />
|
|
</section>
|
|
|
|
<section className="rounded-xl border p-6 space-y-4">
|
|
<div>
|
|
<h2 className="font-semibold text-lg">{m.settings.modelPrefs.title}</h2>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{m.settings.modelPrefs.description}
|
|
</p>
|
|
</div>
|
|
<ModelPrefsForm initialPrefs={modelPrefs ?? null} />
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|