feat(claim): quantity-aware claims via item_claims; per-guest unclaim

This commit is contained in:
belisards
2026-05-03 16:22:22 -03:00
parent 32f9403bd8
commit 844951c832
2 changed files with 116 additions and 134 deletions

View File

@@ -1,89 +1,105 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm'; import { and, eq, ne, sql } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db'; import { db, wishlistItems, wishlists, itemClaims, guests } from '@/lib/db';
import { createId } from '@paralleldrive/cuid2'; import { getGuestFromRequest } from '@/lib/auth/tokens';
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
const { id } = await params; const guest = await getGuestFromRequest(request);
const body = await request.json(); if (!guest) {
const { name, note } = body; return NextResponse.json({ error: 'Convite necessário' }, { status: 401 });
// 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 }
);
} }
// Check if item is already claimed const { id } = await params;
if (item[0].claimedByToken) { 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<number>`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( return NextResponse.json(
{ error: 'Item is already claimed' }, { error: `Apenas ${remaining} disponível(is)`, remaining },
{ status: 409 } { status: 409 }
); );
} }
// Check if wishlist is public // Upsert: replace existing claim for this (item, guest)
const wishlist = await db const existing = await db
.select() .select()
.from(wishlists) .from(itemClaims)
.where(eq(wishlists.id, item[0].wishlistId)) .where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, guest.id)))
.limit(1); .limit(1);
if (wishlist.length === 0) { if (existing.length > 0) {
return NextResponse.json( await db
{ error: 'Wishlist not found' }, .update(itemClaims)
{ status: 404 } .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) { await db.update(wishlistItems).set({ updatedAt: new Date() }).where(eq(wishlistItems.id, id));
return NextResponse.json(
{ error: 'This wishlist is private' },
{ status: 403 }
);
}
// Generate unique claim token const updated = await fetchItemWithClaims(id);
const claimToken = createId(); return NextResponse.json({ success: true, item: updated }, { status: 201 });
} catch (err) {
// Update item with claim information console.error('Error claiming item:', err);
const updatedItem = await db return NextResponse.json({ error: 'Failed to claim item' }, { status: 500 });
.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 }
);
} }
} }
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),
};
}

View File

@@ -1,84 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db, wishlistItems, wishlists } from '@/lib/db'; import { db, wishlistItems, wishlists, itemClaims } from '@/lib/db';
import { getGuestFromRequest, verifyAdminToken } from '@/lib/auth/tokens';
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { 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 { id } = await params;
const body = await request.json().catch(() => ({}));
// Get the item const targetGuestId = isAdmin && typeof body?.guestId === 'string' ? body.guestId : guest?.id;
const item = await db if (!targetGuestId) {
.select() return NextResponse.json({ error: 'Missing guest' }, { status: 400 });
.from(wishlistItems)
.where(eq(wishlistItems.id, id))
.limit(1);
if (item.length === 0) {
return NextResponse.json(
{ error: 'Item not found' },
{ status: 404 }
);
} }
// Check if item is actually claimed const itemRows = await db.select().from(wishlistItems).where(eq(wishlistItems.id, id)).limit(1);
if (!item[0].claimedByToken) { if (itemRows.length === 0) return NextResponse.json({ error: 'Item not found' }, { status: 404 });
return NextResponse.json(
{ error: 'Item is not claimed' }, const wl = await db.select().from(wishlists).where(eq(wishlists.id, itemRows[0].wishlistId)).limit(1);
{ status: 400 } 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 result = await db
const wishlist = await db .delete(itemClaims)
.select() .where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, targetGuestId)))
.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))
.returning(); .returning();
return NextResponse.json( if (result.length === 0) {
{ return NextResponse.json({ error: 'Reserva não encontrada' }, { status: 404 });
success: true, }
message: 'Item unclaimed successfully',
item: updatedItem[0], await db.update(wishlistItems).set({ updatedAt: new Date() }).where(eq(wishlistItems.id, id));
},
{ status: 200 } return NextResponse.json({ success: true });
); } catch (err) {
} catch (error) { console.error('Error unclaiming item:', err);
console.error('Error unclaiming item:', error); return NextResponse.json({ error: 'Failed to unclaim item' }, { status: 500 });
return NextResponse.json(
{ error: 'Failed to unclaim item' },
{ status: 500 }
);
} }
} }