Files
Epicure/apps/web/app/api/v1/users/me/route.ts
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

84 lines
3.8 KiB
TypeScript

import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, eq } from "@epicure/db";
import { z } from "zod";
import { gravatarUrl } from "@/lib/gravatar";
import { USERNAME_PATTERN, isUsernameTaken } from "@/lib/username";
import { isOwnedAvatarKey, getPublicUrl } from "@/lib/storage";
const PatchSchema = z.object({
name: z.string().min(1).max(100).optional(),
locale: z.string().max(10).optional(),
bio: z.string().max(500).optional().nullable(),
privateBio: z.string().max(2000).optional().nullable(),
isPrivate: z.boolean().optional(),
username: z.string().trim().toLowerCase().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").optional(),
// The storage key returned by /api/v1/upload/avatar-presign (not a URL) —
// the server validates it was actually issued to this user and builds the
// public URL itself, so a client can't point avatarUrl at an arbitrary or
// another user's object. `null` reverts to the initials fallback (or
// Gravatar, if useGravatar is on — see below).
avatarKey: z.string().max(500).optional().nullable(),
// Size (MB, rounded up) of the file behind avatarKey — as returned by
// /api/v1/upload/avatar-presign. Ignored unless avatarKey is set.
avatarSizeMb: z.number().int().min(0).max(50).default(0),
// Off by default (Settings → Profile) — Gravatar is looked up by an MD5
// hash of the user's email, sent to a third party.
useGravatar: z.boolean().optional(),
});
export async function PATCH(req: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = PatchSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
if (body.data.username !== undefined && (await isUsernameTaken(body.data.username, session.user.id))) {
return NextResponse.json({ error: "Username already taken" }, { status: 409 });
}
const { avatarKey, avatarSizeMb, useGravatar, ...rest } = body.data;
const updates: Partial<typeof users.$inferInsert> = { ...rest };
// useGravatar is only ever toggled from the settings form, never alongside
// an avatar upload/removal in the same request — so it's fine for these
// two branches to each independently decide the resulting avatarUrl below.
if (useGravatar !== undefined) {
updates.useGravatar = useGravatar;
if (!(await hasCustomAvatar(session.user.id))) {
updates.avatarUrl = useGravatar ? gravatarUrl(session.user.email) : null;
}
}
if (avatarKey !== undefined) {
if (avatarKey === null) {
const gravatarOptedIn = useGravatar ?? (await getUseGravatar(session.user.id));
updates.avatarUrl = gravatarOptedIn ? gravatarUrl(session.user.email) : null;
updates.hasCustomAvatar = false;
updates.avatarSizeMb = 0;
} else {
if (!isOwnedAvatarKey(avatarKey, session.user.id)) {
return NextResponse.json({ error: "Invalid avatar key" }, { status: 400 });
}
updates.avatarUrl = getPublicUrl(avatarKey);
updates.hasCustomAvatar = true;
updates.avatarSizeMb = avatarSizeMb;
}
}
await db.update(users).set(updates).where(eq(users.id, session.user.id));
return NextResponse.json({ ok: true, avatarUrl: updates.avatarUrl });
}
async function hasCustomAvatar(userId: string): Promise<boolean> {
const row = await db.query.users.findFirst({ where: eq(users.id, userId), columns: { hasCustomAvatar: true } });
return row?.hasCustomAvatar ?? false;
}
async function getUseGravatar(userId: string): Promise<boolean> {
const row = await db.query.users.findFirst({ where: eq(users.id, userId), columns: { useGravatar: true } });
return row?.useGravatar ?? false;
}