import { transaction } from "@/lib/db"; import { MAX_CODE_ATTEMPTS, codeMatches, } from "@/lib/otp"; import { issueSession, toProfile } from "@/lib/users"; export async function POST(req: Request) { const { email, code } = await req.json(); if (!email?.trim() || !/^\d{6}$/.test(code ?? "")) { return Response.json( { error: "Email and a 6-digit code are required." }, { status: 400 }, ); } const normalized = email.trim().toLowerCase(); try { // Charge the attempt before comparing so concurrent guesses can't race // past the cap, and so a correct guess still costs one of the five. Wrap // the attempt charge, verification, and code cleanup in one transaction. const result = await transaction(async (tx) => { const attempts = await tx<{ code_hash: string; attempts: number }>` UPDATE email_verification_codes SET attempts = attempts + 1 WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP RETURNING code_hash, attempts `; const record = attempts[0]; if ( !record || record.attempts > MAX_CODE_ATTEMPTS || !codeMatches(record.code_hash, normalized, code) ) { return { kind: "invalid" as const }; } const rows = await tx<{ id: string; name: string; email: string; role: string | null; }>` UPDATE users SET email_verified = TRUE WHERE email = ${normalized} RETURNING id, name, email, role `; const user = rows[0]; if (!user) { return { kind: "not_found" as const }; } await tx`DELETE FROM email_verification_codes WHERE email = ${normalized}`; return { kind: "ok" as const, user }; }); if (result.kind === "invalid") { return Response.json( { error: "Invalid or expired verification code." }, { status: 400 }, ); } if (result.kind === "not_found") { return Response.json({ error: "User not found." }, { status: 404 }); } const session = issueSession(result.user); return Response.json({ data: { token: session.token, user: toProfile(result.user) }, }); } catch (error) { console.error("[VERIFY]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } }