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