fix(scanner): idempotent Mark-Owned and reliable bulk actions

Add per-row in-flight locks so double-tap cannot duplicate owned POSTs.
Pass bulk action/target directly instead of setTimeout state races.
Log collection card adds via logCollectionActivity and fix rows.length
checks in the collection cards POST handler.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-05-27 13:26:50 -05:00
parent a251dacbd3
commit 5088155016
2 changed files with 56 additions and 30 deletions

View file

@ -1,5 +1,5 @@
import { sql } from '@vercel/postgres'; import { sql } from '@vercel/postgres';
import { getUserFromRequest } from '../../../../lib/permission-middleware'; import { getUserFromRequest, logCollectionActivity } from '../../../../lib/permission-middleware';
import { isValidSlug } from '../../../../lib/slug-utils'; import { isValidSlug } from '../../../../lib/slug-utils';
export default async function handler(req, res) { export default async function handler(req, res) {
@ -107,18 +107,20 @@ export default async function handler(req, res) {
} }
// Check if card exists // Check if card exists
const cardCheck = await sql`SELECT id FROM cards WHERE id = ${cardId}`; const cardCheck = await sql`SELECT id, name FROM cards WHERE id = ${cardId}`;
if (cardCheck.length === 0) { if (cardCheck.rows.length === 0) {
return res.status(404).json({ error: 'Card not found' }); return res.status(404).json({ error: 'Card not found' });
} }
const cardRecord = cardCheck.rows[0];
// Check if card already exists in collection // Check if card already exists in collection
const existingResult = await sql` const existingResult = await sql`
SELECT * FROM collection_cards SELECT * FROM collection_cards
WHERE collection_id = ${collection.id} AND card_id = ${cardId} WHERE collection_id = ${collection.id} AND card_id = ${cardId}
`; `;
if (existingResult.length > 0) { if (existingResult.rows.length > 0) {
// Update quantity if card already exists // Update quantity if card already exists
const result = await sql` const result = await sql`
UPDATE collection_cards UPDATE collection_cards
@ -127,9 +129,16 @@ export default async function handler(req, res) {
RETURNING * RETURNING *
`; `;
await logCollectionActivity(collection.id, user.userId, 'card_added', {
cardId,
cardName: cardRecord.name,
quantityAdded: quantity,
newQuantity: result.rows[0]?.quantity,
});
res.status(200).json({ res.status(200).json({
message: 'Card quantity updated in collection', message: 'Card quantity updated in collection',
card: result[0] card: result.rows[0]
}); });
} else { } else {
// Add new card to collection // Add new card to collection
@ -139,9 +148,15 @@ export default async function handler(req, res) {
RETURNING * RETURNING *
`; `;
await logCollectionActivity(collection.id, user.userId, 'card_added', {
cardId,
cardName: cardRecord.name,
quantityAdded: quantity,
});
res.status(201).json({ res.status(201).json({
message: 'Card added to collection', message: 'Card added to collection',
card: result[0] card: result.rows[0]
}); });
} }

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import CameraScanner from '../components/CameraScanner'; import CameraScanner from '../components/CameraScanner';
@ -22,6 +22,7 @@ export default function Scanner() {
const [bulkAction, setBulkAction] = useState(''); // 'owned', 'collection', 'deck' const [bulkAction, setBulkAction] = useState(''); // 'owned', 'collection', 'deck'
const [bulkTarget, setBulkTarget] = useState(''); const [bulkTarget, setBulkTarget] = useState('');
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const addingInFlightRef = useRef(new Set());
// Mana symbol settings // Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
@ -140,11 +141,15 @@ export default function Scanner() {
// Individual card actions // Individual card actions
const addSingleCardToOwned = async (card) => { const addSingleCardToOwned = async (card) => {
if (addingInFlightRef.current.has(card.id)) return;
addingInFlightRef.current.add(card.id);
try { try {
await addToOwnedCards(card); await addToOwnedCards(card);
markCardAsProcessed(card.id, 'owned'); markCardAsProcessed(card.id, 'owned');
} catch (error) { } catch (error) {
console.error('Error adding card to owned:', error); console.error('Error adding card to owned:', error);
} finally {
addingInFlightRef.current.delete(card.id);
} }
}; };
@ -167,25 +172,34 @@ export default function Scanner() {
}; };
// Bulk actions // Bulk actions
const handleBulkAction = async () => { const handleBulkAction = async (actionOverride, targetOverride) => {
if (!bulkAction || selectedCards.size === 0) return; const action = actionOverride ?? bulkAction;
const target = targetOverride ?? bulkTarget;
if (!action || selectedCards.size === 0) return;
setIsProcessing(true); setIsProcessing(true);
try { try {
const cardsToProcess = scannedCards.filter(card => selectedCards.has(card.id)); const cardsToProcess = scannedCards.filter(card => selectedCards.has(card.id));
for (const card of cardsToProcess) { for (const card of cardsToProcess) {
if (bulkAction === 'owned') { if (action === 'owned') {
await addToOwnedCards(card); if (addingInFlightRef.current.has(card.id)) continue;
} else if (bulkAction === 'collection' && bulkTarget) { addingInFlightRef.current.add(card.id);
await addToCollection(card, bulkTarget); try {
} else if (bulkAction === 'deck' && bulkTarget) { await addToOwnedCards(card);
await addToDeck(card, bulkTarget); } finally {
addingInFlightRef.current.delete(card.id);
}
} else if (action === 'collection' && target) {
await addToCollection(card, target);
} else if (action === 'deck' && target) {
await addToDeck(card, target);
} else {
continue;
} }
markCardAsProcessed(card.id, bulkAction); markCardAsProcessed(card.id, action);
} }
// Clear selections and reset bulk action state
setSelectedCards(new Set()); setSelectedCards(new Set());
setBulkAction(''); setBulkAction('');
setBulkTarget(''); setBulkTarget('');
@ -655,10 +669,7 @@ export default function Scanner() {
{/* Quick Actions */} {/* Quick Actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={() => { onClick={() => handleBulkAction('owned')}
setBulkAction('owned');
handleBulkAction();
}}
disabled={isProcessing} disabled={isProcessing}
className="px-4 py-2 rounded-lg font-medium hover:opacity-80 disabled:opacity-50 flex items-center gap-2" className="px-4 py-2 rounded-lg font-medium hover:opacity-80 disabled:opacity-50 flex items-center gap-2"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }} style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
@ -669,10 +680,10 @@ export default function Scanner() {
{collections.length > 0 && ( {collections.length > 0 && (
<select <select
onChange={(e) => { onChange={(e) => {
if (e.target.value) { const collectionId = e.target.value;
setBulkAction('collection'); e.target.value = '';
setBulkTarget(e.target.value); if (collectionId) {
setTimeout(() => handleBulkAction(), 100); handleBulkAction('collection', collectionId);
} }
}} }}
disabled={isProcessing} disabled={isProcessing}
@ -691,10 +702,10 @@ export default function Scanner() {
{decks.length > 0 && ( {decks.length > 0 && (
<select <select
onChange={(e) => { onChange={(e) => {
if (e.target.value) { const deckId = e.target.value;
setBulkAction('deck'); e.target.value = '';
setBulkTarget(e.target.value); if (deckId) {
setTimeout(() => handleBulkAction(), 100); handleBulkAction('deck', deckId);
} }
}} }}
disabled={isProcessing} disabled={isProcessing}