fix(scanner): idempotent Mark-Owned and reliable bulk actions (#41)
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:
parent
a251dacbd3
commit
55af7e3c90
2 changed files with 56 additions and 30 deletions
|
|
@ -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]
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setBulkAction('owned');
|
||||
handleBulkAction();
|
||||
}}
|
||||
onClick={() => handleBulkAction('owned')}
|
||||
disabled={isProcessing}
|
||||
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' }}
|
||||
|
|
@ -669,10 +680,10 @@ export default function Scanner() {
|
|||
{collections.length > 0 && (
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
setBulkAction('collection');
|
||||
setBulkTarget(e.target.value);
|
||||
setTimeout(() => handleBulkAction(), 100);
|
||||
const collectionId = e.target.value;
|
||||
e.target.value = '';
|
||||
if (collectionId) {
|
||||
handleBulkAction('collection', collectionId);
|
||||
}
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
|
|
@ -691,10 +702,10 @@ export default function Scanner() {
|
|||
{decks.length > 0 && (
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
setBulkAction('deck');
|
||||
setBulkTarget(e.target.value);
|
||||
setTimeout(() => handleBulkAction(), 100);
|
||||
const deckId = e.target.value;
|
||||
e.target.value = '';
|
||||
if (deckId) {
|
||||
handleBulkAction('deck', deckId);
|
||||
}
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
|
|
|
|||
Loading…
Reference in a new issue