import { sql } from '../sql.js'; import { cardNumbersEquivalent, normalizeCardNumber } from '../card-number-utils.js'; export { normalizeCardNumber }; 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 cardNumbersEquivalent(catalogNumber, 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; }