From 844951c8328716537b2e8952020735c1836dd6f7 Mon Sep 17 00:00:00 2001 From: belisards Date: Sun, 3 May 2026 16:22:22 -0300 Subject: [PATCH] feat(claim): quantity-aware claims via item_claims; per-guest unclaim --- app/api/public/items/[id]/claim/route.ts | 150 ++++++++++++--------- app/api/public/items/[id]/unclaim/route.ts | 100 +++++--------- 2 files changed, 116 insertions(+), 134 deletions(-) diff --git a/app/api/public/items/[id]/claim/route.ts b/app/api/public/items/[id]/claim/route.ts index 6c3a76b..0aa23f6 100644 --- a/app/api/public/items/[id]/claim/route.ts +++ b/app/api/public/items/[id]/claim/route.ts @@ -1,89 +1,105 @@ import { NextRequest, NextResponse } from 'next/server'; -import { eq } from 'drizzle-orm'; -import { db, wishlistItems, wishlists } from '@/lib/db'; -import { createId } from '@paralleldrive/cuid2'; +import { and, eq, ne, sql } from 'drizzle-orm'; +import { db, wishlistItems, wishlists, itemClaims, guests } from '@/lib/db'; +import { getGuestFromRequest } from '@/lib/auth/tokens'; export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { - const { id } = await params; - const body = await request.json(); - const { name, note } = body; - - // Get the item - const item = await db - .select() - .from(wishlistItems) - .where(eq(wishlistItems.id, id)) - .limit(1); - - if (item.length === 0) { - return NextResponse.json( - { error: 'Item not found' }, - { status: 404 } - ); + const guest = await getGuestFromRequest(request); + if (!guest) { + return NextResponse.json({ error: 'Convite necessário' }, { status: 401 }); } - // Check if item is already claimed - if (item[0].claimedByToken) { + const { id } = await params; + const body = await request.json().catch(() => ({})); + const requestedQty = Math.max(1, Math.floor(Number(body?.quantity ?? 1))); + const note = (body?.note ?? '').toString().trim() || null; + + const itemRows = await db.select().from(wishlistItems).where(eq(wishlistItems.id, id)).limit(1); + if (itemRows.length === 0) return NextResponse.json({ error: 'Item not found' }, { status: 404 }); + const item = itemRows[0]; + + const wl = await db.select().from(wishlists).where(eq(wishlists.id, item.wishlistId)).limit(1); + if (wl.length === 0) return NextResponse.json({ error: 'Wishlist not found' }, { status: 404 }); + if (!wl[0].isPublic) return NextResponse.json({ error: 'This wishlist is private' }, { status: 403 }); + + // Sum quantities reserved by OTHER guests + const otherSumRow = await db + .select({ total: sql`COALESCE(SUM(${itemClaims.quantity}), 0)` }) + .from(itemClaims) + .where(and(eq(itemClaims.itemId, id), ne(itemClaims.guestId, guest.id))); + const otherSum = Number(otherSumRow[0]?.total ?? 0); + + if (otherSum + requestedQty > item.quantity) { + const remaining = Math.max(0, item.quantity - otherSum); return NextResponse.json( - { error: 'Item is already claimed' }, + { error: `Apenas ${remaining} disponível(is)`, remaining }, { status: 409 } ); } - // Check if wishlist is public - const wishlist = await db + // Upsert: replace existing claim for this (item, guest) + const existing = await db .select() - .from(wishlists) - .where(eq(wishlists.id, item[0].wishlistId)) + .from(itemClaims) + .where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, guest.id))) .limit(1); - if (wishlist.length === 0) { - return NextResponse.json( - { error: 'Wishlist not found' }, - { status: 404 } - ); + if (existing.length > 0) { + await db + .update(itemClaims) + .set({ quantity: requestedQty, note, updatedAt: new Date() }) + .where(eq(itemClaims.id, existing[0].id)); + } else { + await db.insert(itemClaims).values({ + itemId: id, + guestId: guest.id, + quantity: requestedQty, + note, + }); } - if (!wishlist[0].isPublic) { - return NextResponse.json( - { error: 'This wishlist is private' }, - { status: 403 } - ); - } + await db.update(wishlistItems).set({ updatedAt: new Date() }).where(eq(wishlistItems.id, id)); - // Generate unique claim token - const claimToken = createId(); - - // Update item with claim information - const updatedItem = await db - .update(wishlistItems) - .set({ - claimedByName: name || null, - claimedByNote: note || null, - claimedByToken: claimToken, - claimedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(wishlistItems.id, id)) - .returning(); - - return NextResponse.json( - { - success: true, - claimToken, - item: updatedItem[0], - }, - { status: 201 } - ); - } catch (error) { - console.error('Error claiming item:', error); - return NextResponse.json( - { error: 'Failed to claim item' }, - { status: 500 } - ); + const updated = await fetchItemWithClaims(id); + return NextResponse.json({ success: true, item: updated }, { status: 201 }); + } catch (err) { + console.error('Error claiming item:', err); + return NextResponse.json({ error: 'Failed to claim item' }, { status: 500 }); } } + +async function fetchItemWithClaims(itemId: string) { + const itemRows = await db.select().from(wishlistItems).where(eq(wishlistItems.id, itemId)).limit(1); + if (itemRows.length === 0) return null; + const claims = await db + .select({ + id: itemClaims.id, + quantity: itemClaims.quantity, + note: itemClaims.note, + isPurchased: itemClaims.isPurchased, + claimedAt: itemClaims.claimedAt, + guestId: guests.id, + guestName: guests.name, + }) + .from(itemClaims) + .innerJoin(guests, eq(itemClaims.guestId, guests.id)) + .where(eq(itemClaims.itemId, itemId)); + const claimedQuantity = claims.reduce((s, c) => s + c.quantity, 0); + return { + ...itemRows[0], + claims: claims.map((c) => ({ + id: c.id, + quantity: c.quantity, + note: c.note, + isPurchased: c.isPurchased, + claimedAt: c.claimedAt, + guest: { id: c.guestId, name: c.guestName }, + })), + claimedQuantity, + remainingQuantity: Math.max(0, itemRows[0].quantity - claimedQuantity), + }; +} diff --git a/app/api/public/items/[id]/unclaim/route.ts b/app/api/public/items/[id]/unclaim/route.ts index 66094dc..38dc88b 100644 --- a/app/api/public/items/[id]/unclaim/route.ts +++ b/app/api/public/items/[id]/unclaim/route.ts @@ -1,84 +1,50 @@ import { NextRequest, NextResponse } from 'next/server'; -import { eq } from 'drizzle-orm'; -import { db, wishlistItems, wishlists } from '@/lib/db'; +import { and, eq } from 'drizzle-orm'; +import { db, wishlistItems, wishlists, itemClaims } from '@/lib/db'; +import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens'; export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const guest = await getGuestFromRequest(request); + const isAdmin = verifyAdminToken(request); + if (!guest && !isAdmin) { + return NextResponse.json({ error: 'Convite necessário' }, { status: 401 }); + } + const { id } = await params; + const body = await request.json().catch(() => ({})); - // Get the item - const item = await db - .select() - .from(wishlistItems) - .where(eq(wishlistItems.id, id)) - .limit(1); - - if (item.length === 0) { - return NextResponse.json( - { error: 'Item not found' }, - { status: 404 } - ); + const targetGuestId = isAdmin && typeof body?.guestId === 'string' ? body.guestId : guest?.id; + if (!targetGuestId) { + return NextResponse.json({ error: 'Missing guest' }, { status: 400 }); } - // Check if item is actually claimed - if (!item[0].claimedByToken) { - return NextResponse.json( - { error: 'Item is not claimed' }, - { status: 400 } - ); + const itemRows = await db.select().from(wishlistItems).where(eq(wishlistItems.id, id)).limit(1); + if (itemRows.length === 0) return NextResponse.json({ error: 'Item not found' }, { status: 404 }); + + const wl = await db.select().from(wishlists).where(eq(wishlists.id, itemRows[0].wishlistId)).limit(1); + if (wl.length === 0) return NextResponse.json({ error: 'Wishlist not found' }, { status: 404 }); + if (!wl[0].isPublic && !isAdmin) { + return NextResponse.json({ error: 'This wishlist is private' }, { status: 403 }); } - // Check if wishlist is public - const wishlist = await db - .select() - .from(wishlists) - .where(eq(wishlists.id, item[0].wishlistId)) - .limit(1); - - if (wishlist.length === 0) { - return NextResponse.json( - { error: 'Wishlist not found' }, - { status: 404 } - ); - } - - if (!wishlist[0].isPublic) { - return NextResponse.json( - { error: 'This wishlist is private' }, - { status: 403 } - ); - } - - // Remove claim information (honor system - no verification) - const updatedItem = await db - .update(wishlistItems) - .set({ - claimedByName: null, - claimedByNote: null, - claimedByToken: null, - claimedAt: null, - isPurchased: false, - updatedAt: new Date(), - }) - .where(eq(wishlistItems.id, id)) + const result = await db + .delete(itemClaims) + .where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, targetGuestId))) .returning(); - return NextResponse.json( - { - success: true, - message: 'Item unclaimed successfully', - item: updatedItem[0], - }, - { status: 200 } - ); - } catch (error) { - console.error('Error unclaiming item:', error); - return NextResponse.json( - { error: 'Failed to unclaim item' }, - { status: 500 } - ); + if (result.length === 0) { + return NextResponse.json({ error: 'Reserva não encontrada' }, { status: 404 }); + } + + await db.update(wishlistItems).set({ updatedAt: new Date() }).where(eq(wishlistItems.id, id)); + + return NextResponse.json({ success: true }); + } catch (err) { + console.error('Error unclaiming item:', err); + return NextResponse.json({ error: 'Failed to unclaim item' }, { status: 500 }); } }