fix(quantity): enforce claim limits on backend, fix quantity field in admin form
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,44 +26,69 @@ export async function POST(
|
|||||||
if (wl.length === 0) return NextResponse.json({ error: 'Wishlist not found' }, { status: 404 });
|
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 });
|
if (!wl[0].isPublic) return NextResponse.json({ error: 'This wishlist is private' }, { status: 403 });
|
||||||
|
|
||||||
// Sum quantities reserved by OTHER guests
|
// Atomically check remaining quantity and upsert the claim inside a synchronous
|
||||||
const otherSumRow = await db
|
// transaction to prevent race conditions (better-sqlite3 is synchronous).
|
||||||
.select({ total: sql<number>`COALESCE(SUM(${itemClaims.quantity}), 0)` })
|
type TxResult = { ok: true } | { ok: false; remaining: number };
|
||||||
.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 txResult: TxResult = db.transaction((tx) => {
|
||||||
const remaining = Math.max(0, item.quantity - otherSum);
|
// Re-read item quantity inside the transaction for an accurate snapshot
|
||||||
|
const itemSnap = tx
|
||||||
|
.select({ quantity: wishlistItems.quantity })
|
||||||
|
.from(wishlistItems)
|
||||||
|
.where(eq(wishlistItems.id, id))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (itemSnap.length === 0) return { ok: false, remaining: 0 };
|
||||||
|
const itemQty = itemSnap[0].quantity;
|
||||||
|
|
||||||
|
// Sum quantities reserved by OTHER guests
|
||||||
|
const otherSumRow = tx
|
||||||
|
.select({ total: sql<number>`COALESCE(SUM(${itemClaims.quantity}), 0)` })
|
||||||
|
.from(itemClaims)
|
||||||
|
.where(and(eq(itemClaims.itemId, id), ne(itemClaims.guestId, guest.id)))
|
||||||
|
.all();
|
||||||
|
const otherSum = Number(otherSumRow[0]?.total ?? 0);
|
||||||
|
|
||||||
|
if (otherSum + requestedQty > itemQty) {
|
||||||
|
const remaining = Math.max(0, itemQty - otherSum);
|
||||||
|
return { ok: false, remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert: replace existing claim for this (item, guest)
|
||||||
|
const existing = tx
|
||||||
|
.select({ id: itemClaims.id })
|
||||||
|
.from(itemClaims)
|
||||||
|
.where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, guest.id)))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
tx.update(itemClaims)
|
||||||
|
.set({ quantity: requestedQty, note, updatedAt: new Date() })
|
||||||
|
.where(eq(itemClaims.id, existing[0].id))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
tx.insert(itemClaims)
|
||||||
|
.values({ itemId: id, guestId: guest.id, quantity: requestedQty, note })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.update(wishlistItems)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(wishlistItems.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!txResult.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Apenas ${remaining} disponível(is)`, remaining },
|
{ error: `Apenas ${txResult.remaining} disponível(is)`, remaining: txResult.remaining },
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert: replace existing claim for this (item, guest)
|
|
||||||
const existing = await db
|
|
||||||
.select()
|
|
||||||
.from(itemClaims)
|
|
||||||
.where(and(eq(itemClaims.itemId, id), eq(itemClaims.guestId, guest.id)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.update(wishlistItems).set({ updatedAt: new Date() }).where(eq(wishlistItems.id, id));
|
|
||||||
|
|
||||||
const updated = await fetchItemWithClaims(id);
|
const updated = await fetchItemWithClaims(id);
|
||||||
return NextResponse.json({ success: true, item: updated }, { status: 201 });
|
return NextResponse.json({ success: true, item: updated }, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -65,6 +65,21 @@ export default function ItemForm({ item, onSubmit, onCancel, mode, error }: Item
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Quantidade
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
className="w-full px-2 py-1.5 text-base border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
value={formData.quantity ?? 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, quantity: Math.max(1, parseInt(e.target.value) || 1) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-base font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Description
|
Description
|
||||||
|
|||||||
Reference in New Issue
Block a user