From cf5c0558f1547b6d7025b0f4279eadd57c3f449f Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Thu, 28 May 2026 09:47:59 -0500 Subject: [PATCH] Auto-link pending scan submissions after catalog sync imports. (#51) When a set lands via runCatalogSync, match pending card_submissions by set/name/number to catalog rows and approve them with promoted_card_id instead of leaving them in the admin queue. Co-authored-by: Cursor --- lib/card-import/reconcile-submissions.js | 177 ++++++++++++++++++ lib/card-import/sync-catalog.js | 13 ++ pages/admin/card-import.js | 20 +- .../card-import-reconcile-submissions.test.js | 88 +++++++++ 4 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 lib/card-import/reconcile-submissions.js create mode 100644 test/lib/card-import-reconcile-submissions.test.js diff --git a/lib/card-import/reconcile-submissions.js b/lib/card-import/reconcile-submissions.js new file mode 100644 index 0000000..75acfa6 --- /dev/null +++ b/lib/card-import/reconcile-submissions.js @@ -0,0 +1,177 @@ +import { sql } from '@vercel/postgres'; + +/** + * Normalize collector numbers so "015/208" and "15/208" compare equal. + */ +export function normalizeCardNumber(value) { + if (value == null || value === '') return ''; + const trimmed = String(value).trim(); + const slashIndex = trimmed.indexOf('/'); + if (slashIndex === -1) { + return trimmed.toLowerCase(); + } + + const numerator = trimmed.slice(0, slashIndex).trim(); + const denominator = trimmed.slice(slashIndex + 1).trim(); + const normalizedNumerator = String(parseInt(numerator, 10)); + if (normalizedNumerator === 'NaN') { + return trimmed.toLowerCase(); + } + + return `${normalizedNumerator}/${denominator}`.toLowerCase(); +} + +function normalizeGame(value) { + if (!value || typeof value !== 'string') return null; + const lower = value.trim().toLowerCase(); + if (lower === 'mtg' || lower === 'magic') return 'MTG'; + if (lower === 'pokemon' || lower === 'pokémon') return 'Pokemon'; + if (lower === 'lorcana') return 'Lorcana'; + return value.trim(); +} + +function importedSetGame(game) { + return game === 'pokemon' ? 'Pokemon' : 'MTG'; +} + +/** + * Whether a pending submission's OCR payload refers to the set that was just imported. + */ +export function submissionPayloadMatchesSet(payload, { game, setCode, setName }) { + if (!payload || typeof payload !== 'object') return false; + + const payloadGame = normalizeGame(payload.game); + const expectedGame = importedSetGame(game); + if (payloadGame && payloadGame !== expectedGame) { + return false; + } + + const code = (setCode || '').trim().toLowerCase(); + const name = (setName || '').trim().toLowerCase(); + const payloadSetCode = (payload.setCode || '').trim().toLowerCase(); + const payloadSet = (payload.set || '').trim().toLowerCase(); + + if (!code && !name) return false; + + return Boolean( + (payloadSetCode && payloadSetCode === code) || + (payloadSet && (payloadSet === code || payloadSet === name)) + ); +} + +function cardNumberMatches(catalogNumber, payloadNumber) { + if (!payloadNumber) return true; + if (!catalogNumber) return false; + return normalizeCardNumber(catalogNumber) === normalizeCardNumber(payloadNumber); +} + +/** + * Pick the catalog row that satisfies a submission payload within an imported set. + * Returns the card id or null when ambiguous / no match. + */ +export function pickCatalogCardForPayload(catalogRows, payload) { + if (!payload?.name || catalogRows.length === 0) return null; + + const cardNumber = payload.cardNumber?.trim() || null; + const matches = catalogRows.filter((row) => cardNumberMatches(row.card_number, cardNumber)); + + if (cardNumber) { + return matches.length === 1 ? matches[0].id : null; + } + + return matches.length === 1 ? matches[0].id : null; +} + +/** + * Find pending submissions for a set and link them to catalog rows when unambiguous. + */ +export async function reconcileSubmissionsForImportedSet({ game, setCode, setName }) { + const summary = { + game, + setCode, + setName, + matched: 0, + skipped: 0, + details: [], + }; + + const pendingResult = await sql` + SELECT id, ocr_payload + FROM card_submissions + WHERE status = 'pending' + ORDER BY created_at ASC + `; + + const relevant = pendingResult.rows.filter((row) => { + const payload = + typeof row.ocr_payload === 'string' + ? JSON.parse(row.ocr_payload) + : row.ocr_payload || {}; + return submissionPayloadMatchesSet(payload, { game, setCode, setName }); + }); + + if (relevant.length === 0) { + return summary; + } + + const catalogResult = await sql` + SELECT id, name, set_name, set_code, card_number, game + FROM cards + WHERE LOWER(set_code) = LOWER(${setCode}) + OR LOWER(set_name) = LOWER(${setName || setCode}) + `; + + const catalogRows = catalogResult.rows.filter( + (row) => row.game === importedSetGame(game) + ); + + for (const submission of relevant) { + const payload = + typeof submission.ocr_payload === 'string' + ? JSON.parse(submission.ocr_payload) + : submission.ocr_payload || {}; + + const trimmedName = payload.name?.trim().toLowerCase(); + const nameMatches = catalogRows.filter( + (row) => row.name?.trim().toLowerCase() === trimmedName + ); + const cardId = pickCatalogCardForPayload(nameMatches, payload); + + if (!cardId) { + summary.skipped += 1; + continue; + } + + const updateResult = await sql` + UPDATE card_submissions + SET + status = 'approved', + promoted_card_id = ${cardId}, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${submission.id} + AND status = 'pending' + RETURNING id + `; + + if (updateResult.rows.length === 0) { + summary.skipped += 1; + continue; + } + + summary.matched += 1; + summary.details.push({ + submissionId: submission.id, + cardId, + name: payload.name, + cardNumber: payload.cardNumber || null, + }); + } + + if (summary.matched > 0) { + console.log( + `[reconcileSubmissionsForImportedSet] ${game}/${setCode}: matched ${summary.matched} submission(s)` + ); + } + + return summary; +} diff --git a/lib/card-import/sync-catalog.js b/lib/card-import/sync-catalog.js index 3b431f9..19f051c 100644 --- a/lib/card-import/sync-catalog.js +++ b/lib/card-import/sync-catalog.js @@ -1,6 +1,7 @@ import { discoverMissingMtgSets, discoverMissingPokemonSets } from './discover.js'; import { importMtgSet } from './mtg.js'; import { importPokemonSet } from './pokemon.js'; +import { reconcileSubmissionsForImportedSet } from './reconcile-submissions.js'; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -60,6 +61,8 @@ export async function runCatalogSync(options = {}) { pendingMtgSets: missingMtg.length, pendingPokemonSets: missingPokemon.length, lorcana: 'skipped — manual Lorcana set map update required', + submissionsReconciled: 0, + reconciliation: [], }; for (let index = 0; index < queue.length; index += 1) { @@ -78,6 +81,16 @@ export async function runCatalogSync(options = {}) { }); summary.imported += result.imported; summary.skipped += result.skipped; + + const reconcileResult = await reconcileSubmissionsForImportedSet({ + game: item.game, + setCode: item.setCode, + setName: item.name, + }); + summary.submissionsReconciled += reconcileResult.matched; + if (reconcileResult.matched > 0 || reconcileResult.skipped > 0) { + summary.reconciliation.push(reconcileResult); + } } catch (error) { console.error(`[runCatalogSync] Failed ${item.game}/${item.setCode}:`, error); summary.errors.push({ diff --git a/pages/admin/card-import.js b/pages/admin/card-import.js index 5fe5f10..7899d7b 100644 --- a/pages/admin/card-import.js +++ b/pages/admin/card-import.js @@ -174,7 +174,7 @@ const CardImport = () => {

Import up to three newest missing MTG and Pokémon sets — the same job the weekly Vercel cron runs. - Use this to backfill recent releases without curl. + Pending scan submissions for those sets are auto-linked to the catalog when a unique match exists.