From 50881550165535602d77d642beb2421830cd33e2 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 27 May 2026 13:26:50 -0500 Subject: [PATCH] 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 --- pages/api/collections/[identifier]/cards.js | 27 +++++++--- pages/scanner.js | 59 ++++++++++++--------- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/pages/api/collections/[identifier]/cards.js b/pages/api/collections/[identifier]/cards.js index 6766b36..b4e15d8 100644 --- a/pages/api/collections/[identifier]/cards.js +++ b/pages/api/collections/[identifier]/cards.js @@ -1,5 +1,5 @@ import { sql } from '@vercel/postgres'; -import { getUserFromRequest } from '../../../../lib/permission-middleware'; +import { getUserFromRequest, logCollectionActivity } from '../../../../lib/permission-middleware'; import { isValidSlug } from '../../../../lib/slug-utils'; export default async function handler(req, res) { @@ -107,18 +107,20 @@ export default async function handler(req, res) { } // Check if card exists - const cardCheck = await sql`SELECT id FROM cards WHERE id = ${cardId}`; - if (cardCheck.length === 0) { + const cardCheck = await sql`SELECT id, name FROM cards WHERE id = ${cardId}`; + if (cardCheck.rows.length === 0) { return res.status(404).json({ error: 'Card not found' }); } + const cardRecord = cardCheck.rows[0]; + // Check if card already exists in collection const existingResult = await sql` SELECT * FROM collection_cards WHERE collection_id = ${collection.id} AND card_id = ${cardId} `; - if (existingResult.length > 0) { + if (existingResult.rows.length > 0) { // Update quantity if card already exists const result = await sql` UPDATE collection_cards @@ -127,9 +129,16 @@ export default async function handler(req, res) { RETURNING * `; + await logCollectionActivity(collection.id, user.userId, 'card_added', { + cardId, + cardName: cardRecord.name, + quantityAdded: quantity, + newQuantity: result.rows[0]?.quantity, + }); + res.status(200).json({ message: 'Card quantity updated in collection', - card: result[0] + card: result.rows[0] }); } else { // Add new card to collection @@ -139,9 +148,15 @@ export default async function handler(req, res) { RETURNING * `; + await logCollectionActivity(collection.id, user.userId, 'card_added', { + cardId, + cardName: cardRecord.name, + quantityAdded: quantity, + }); + res.status(201).json({ message: 'Card added to collection', - card: result[0] + card: result.rows[0] }); } diff --git a/pages/scanner.js b/pages/scanner.js index f5a1039..05146e7 100644 --- a/pages/scanner.js +++ b/pages/scanner.js @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useRouter } from 'next/router'; import Layout from '../components/Layout'; import CameraScanner from '../components/CameraScanner'; @@ -22,6 +22,7 @@ export default function Scanner() { const [bulkAction, setBulkAction] = useState(''); // 'owned', 'collection', 'deck' const [bulkTarget, setBulkTarget] = useState(''); const [isProcessing, setIsProcessing] = useState(false); + const addingInFlightRef = useRef(new Set()); // Mana symbol settings const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); @@ -140,11 +141,15 @@ export default function Scanner() { // Individual card actions const addSingleCardToOwned = async (card) => { + if (addingInFlightRef.current.has(card.id)) return; + addingInFlightRef.current.add(card.id); try { await addToOwnedCards(card); markCardAsProcessed(card.id, 'owned'); } catch (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 - const handleBulkAction = async () => { - if (!bulkAction || selectedCards.size === 0) return; + const handleBulkAction = async (actionOverride, targetOverride) => { + const action = actionOverride ?? bulkAction; + const target = targetOverride ?? bulkTarget; + if (!action || selectedCards.size === 0) return; setIsProcessing(true); try { const cardsToProcess = scannedCards.filter(card => selectedCards.has(card.id)); - + for (const card of cardsToProcess) { - if (bulkAction === 'owned') { - await addToOwnedCards(card); - } else if (bulkAction === 'collection' && bulkTarget) { - await addToCollection(card, bulkTarget); - } else if (bulkAction === 'deck' && bulkTarget) { - await addToDeck(card, bulkTarget); + if (action === 'owned') { + if (addingInFlightRef.current.has(card.id)) continue; + addingInFlightRef.current.add(card.id); + try { + await addToOwnedCards(card); + } 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()); setBulkAction(''); setBulkTarget(''); @@ -655,10 +669,7 @@ export default function Scanner() { {/* Quick Actions */}