diff --git a/client/src/components/cards.js b/client/src/components/cards.js index 740df99..ff5e2d8 100644 --- a/client/src/components/cards.js +++ b/client/src/components/cards.js @@ -587,7 +587,7 @@ async function quickAddCard(cardId, deckId) { } } -export async function showCardDetail(cardId) { +export async function showCardDetail(cardId, printingId = null) { try { showLoading(); const [cardResult, ownershipResult] = await Promise.all([ @@ -596,7 +596,11 @@ export async function showCardDetail(cardId) { ]); const card = cardResult.card; const ownership = ownershipResult; - const firstPrinting = card.printings && card.printings.length > 0 ? card.printings[0] : null; + const firstPrinting = card.printings && card.printings.length > 0 + ? (printingId !== null + ? (card.printings.find(p => p.id == printingId) || card.printings[0]) + : card.printings[0]) + : null; hideLoading(); // Parse type line to extract supertypes, types, and subtypes @@ -1461,16 +1465,13 @@ async function showSwapPrintingModal(card, fromPrintingId, quantity, cardId, own try { showLoading(); - // Remove from old printing - await api.setOwnedPrintingQuantity(fromPrintingId, 0); - // Add to new printing (will add to existing if already owned) - const existingOwned = ownership.ownedPrintings.find(op => op.printing_id === toPrintingId); - const newQuantity = (existingOwned ? existingOwned.quantity : 0) + quantity; - await api.setOwnedPrintingQuantity(toPrintingId, newQuantity); + // Move inventory and matching deck usage together in one transaction. + await api.swapOwnedPrinting(fromPrintingId, toPrintingId); showToast('Printing changed!', 'success', 2000); - // Reload card detail - await showCardDetail(cardId); + window.dispatchEvent(new CustomEvent('inventory:refresh')); + // Reload card detail using the newly selected printing + await showCardDetail(cardId, toPrintingId); hideLoading(); } catch (error) { hideLoading(); diff --git a/client/src/components/deckBuilder.js b/client/src/components/deckBuilder.js index c1595b0..89cc59e 100644 --- a/client/src/components/deckBuilder.js +++ b/client/src/components/deckBuilder.js @@ -1738,7 +1738,7 @@ async function showCardModal(printingId) { try { const card = currentDeck.cards.find(c => c.printing_id == printingId); if (card && card.card_id) { - await showCardDetail(card.card_id); + await showCardDetail(card.card_id, card.printing_id); } else { console.error('Card not found or missing card_id:', { printingId, card, currentDeck: currentDeck?.cards?.length }); showToast('Card details not available', 'warning'); @@ -2476,8 +2476,9 @@ function renderExampleHand() { // Click to show modal cardEl.addEventListener('click', async () => { const cardId = cardEl.dataset.cardId; + const printingId = cardEl.dataset.printingId; if (cardId) { - await showCardDetail(cardId); + await showCardDetail(cardId, printingId || null); } }); diff --git a/client/src/components/inventory.js b/client/src/components/inventory.js index 390b2d2..2d5b926 100644 --- a/client/src/components/inventory.js +++ b/client/src/components/inventory.js @@ -19,8 +19,9 @@ let selectedCards = new Set(); // Track selected card IDs for multi-select let selectMode = false; // Whether multi-select mode is active export function setupInventory() { - // Load inventory data when page is shown + // Load inventory data when page is shown or when a printing swap changes ownership. window.addEventListener('page:inventory', loadInventoryData); + window.addEventListener('inventory:refresh', loadInventoryData); // Setup filter listeners setupFilterListeners(); @@ -887,7 +888,8 @@ function renderGridView(container) { cb.innerHTML = ``; } } else { - await showCardDetail(cardId); + const card = inventoryData.cards.find(c => c.card_id === cardId); + await showCardDetail(cardId, card?.selected_printing_id || null); } }); }); @@ -960,7 +962,8 @@ function renderListView(container) { cb.innerHTML = ``; } } else { - await showCardDetail(cardId); + const card = inventoryData.cards.find(c => c.card_id === cardId); + await showCardDetail(cardId, card?.selected_printing_id || null); } }); }); diff --git a/client/src/services/api.js b/client/src/services/api.js index 01c9324..f112426 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -142,6 +142,13 @@ class ApiClient { }); } + async swapOwnedPrinting(fromPrintingId, replacementPrintingId) { + return this.request(`/cards/printings/${fromPrintingId}/swap`, { + method: 'POST', + body: JSON.stringify({ replacementPrintingId }), + }); + } + // Deck methods async getDecks() { return this.request('/decks'); diff --git a/src/routes/cards.js b/src/routes/cards.js index 4b82ba4..4e59ccb 100644 --- a/src/routes/cards.js +++ b/src/routes/cards.js @@ -14,6 +14,7 @@ import { getCardOwnershipStatus, getCardOwnedPrintings, setOwnedPrintingQuantity, + swapOwnedPrinting, getCardOwnershipAndUsage, } from '../services/cardService.js'; import { authenticate } from '../middleware/auth.js'; @@ -242,6 +243,26 @@ router.get('/:id/ownership-usage', authenticate, (req, res, next) => { } }); +/** + * POST /api/cards/printings/:printingId/swap + * Atomically move owned copies to another printing + */ +router.post('/printings/:printingId/swap', authenticate, (req, res, next) => { + try { + const fromPrintingId = parseInt(req.params.printingId); + const toPrintingId = parseInt(req.body.replacementPrintingId); + + if (!Number.isInteger(toPrintingId)) { + return res.status(400).json({ error: 'replacementPrintingId is required' }); + } + + const result = swapOwnedPrinting(req.user.id, fromPrintingId, toPrintingId); + res.json(result); + } catch (error) { + next(error); + } +}); + /** * POST /api/cards/printings/:printingId/quantity * Set owned quantity for a specific printing diff --git a/src/services/cardService.js b/src/services/cardService.js index de92ff4..b5b5d2a 100644 --- a/src/services/cardService.js +++ b/src/services/cardService.js @@ -719,6 +719,123 @@ export function setOwnedPrintingQuantity(userId, printingId, quantity) { return { success: true, quantity }; } +/** + * Atomically move all owned copies from one printing to another. + * Deck rows that explicitly use the old owned printing move with it. + */ +export function swapOwnedPrinting(userId, fromPrintingId, toPrintingId) { + if (fromPrintingId === toPrintingId) { + throw new Error('Replacement printing must be different'); + } + + const source = db.get( + `SELECT op.id as owned_printing_id, op.quantity, p.card_id + FROM owned_printings op + JOIN printings p ON op.printing_id = p.id + WHERE op.user_id = ? AND op.printing_id = ?`, + [userId, fromPrintingId] + ); + + if (!source) { + throw new Error('Owned printing not found'); + } + + const replacement = db.get( + `SELECT card_id FROM printings WHERE id = ?`, + [toPrintingId] + ); + + if (!replacement || replacement.card_id !== source.card_id) { + throw new Error('Replacement printing must be for the same card'); + } + + return db.transaction(() => { + const affectedDeckRows = db.all( + `SELECT dc.id, dc.deck_id, dc.quantity, dc.is_sideboard, dc.is_commander, + COALESCE(dc.board_type, CASE WHEN dc.is_sideboard = 1 THEN 'sideboard' ELSE 'mainboard' END) as board_type + FROM deck_cards dc + JOIN decks d ON d.id = dc.deck_id + WHERE dc.printing_id = ? AND d.user_id = ?`, + [fromPrintingId, userId] + ); + + for (const deckRow of affectedDeckRows) { + const existingDeckRow = db.get( + `SELECT id, quantity, is_commander, + COALESCE(board_type, CASE WHEN is_sideboard = 1 THEN 'sideboard' ELSE 'mainboard' END) as board_type + FROM deck_cards + WHERE deck_id = ? AND printing_id = ? AND is_sideboard = ?`, + [deckRow.deck_id, toPrintingId, deckRow.is_sideboard] + ); + + if (existingDeckRow) { + // Legacy uniqueness is based on is_sideboard. Do not merge two logical + // boards (for example mainboard and maybeboard) that share that flag. + if (existingDeckRow.board_type !== deckRow.board_type) { + continue; + } + + db.run( + `UPDATE deck_cards + SET quantity = ?, is_commander = ? + WHERE id = ?`, + [ + existingDeckRow.quantity + deckRow.quantity, + existingDeckRow.is_commander || deckRow.is_commander ? 1 : 0, + existingDeckRow.id + ] + ); + db.run(`DELETE FROM deck_cards WHERE id = ?`, [deckRow.id]); + } else { + db.run( + `UPDATE deck_cards SET printing_id = ? WHERE id = ?`, + [toPrintingId, deckRow.id] + ); + } + } + + const existingReplacement = db.get( + `SELECT id, quantity + FROM owned_printings + WHERE user_id = ? AND printing_id = ?`, + [userId, toPrintingId] + ); + + if (existingReplacement) { + db.run( + `UPDATE owned_printings + SET quantity = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [existingReplacement.quantity + source.quantity, existingReplacement.id] + ); + } else { + db.run( + `INSERT INTO owned_printings (user_id, printing_id, quantity) + VALUES (?, ?, ?)`, + [userId, toPrintingId, source.quantity] + ); + } + + db.run( + `DELETE FROM owned_printings WHERE id = ?`, + [source.owned_printing_id] + ); + + db.run( + `INSERT INTO owned_cards (user_id, card_id, quantity) VALUES (?, ?, 1) + ON CONFLICT(user_id, card_id) DO UPDATE SET quantity = 1`, + [userId, source.card_id] + ); + + return { + success: true, + fromPrintingId, + toPrintingId, + quantity: source.quantity + }; + }); +} + /** * Get all decks that contain a specific card */ diff --git a/src/services/inventoryService.js b/src/services/inventoryService.js index dc7795d..1e51268 100644 --- a/src/services/inventoryService.js +++ b/src/services/inventoryService.js @@ -17,7 +17,7 @@ export function getInventory(userId, filters = {}) { } = filters; const offset = (page - 1) * limit; - const params = [userId]; + const params = [userId, userId, userId]; const countParams = [userId]; // Base query - get all owned cards with their details @@ -30,7 +30,18 @@ export function getInventory(userId, filters = {}) { c.colors, c.type_line, c.oracle_text, - (SELECT p.image_url FROM printings p WHERE p.card_id = c.id AND p.image_url IS NOT NULL LIMIT 1) as image_url, + (SELECT p.image_url + FROM owned_printings op + JOIN printings p ON op.printing_id = p.id + WHERE op.user_id = ? AND p.card_id = c.id AND p.image_url IS NOT NULL + ORDER BY op.quantity DESC, op.id ASC + LIMIT 1) as image_url, + (SELECT p.id + FROM owned_printings op + JOIN printings p ON op.printing_id = p.id + WHERE op.user_id = ? AND p.card_id = c.id AND p.image_url IS NOT NULL + ORDER BY op.quantity DESC, op.id ASC + LIMIT 1) as selected_printing_id, ( SELECT COALESCE(SUM(op.quantity), 0) FROM owned_printings op