import { sql } from './sql.js'; import { cardNumbersEquivalent, findPrintingByCollectorNumber, } from './card-number-utils.js'; function mapCardRow(card) { return { id: card.id, name: card.name, set_name: card.set_name, set_code: card.set_code, card_number: card.card_number, game: card.game, rarity: card.rarity, image_url: card.image_url, card_type: card.card_type, mana_cost: card.mana_cost, hp: card.power, }; } function buildOcrPayload(fields) { return { name: fields.name?.trim() || null, set: fields.set || null, setCode: fields.setCode || null, cardNumber: fields.cardNumber || null, game: fields.game || null, cardType: fields.cardType || null, rarity: fields.rarity || null, hp: fields.hp || null, manaCost: fields.manaCost || null, rawText: fields.ocrData?.rawText || null, abilities: fields.ocrData?.abilities || [], flavorText: fields.ocrData?.flavorText || null, artist: fields.ocrData?.artist || null, }; } export async function submitScanForReview(userId, fields, candidateIds = []) { const submissionId = await createCardSubmission(userId, fields, candidateIds); const label = [fields.name, fields.set || fields.setCode, fields.cardNumber] .filter(Boolean) .join(' · '); return { type: 'submitted', card: null, submissionId, needsReview: true, message: label ? `"${label}" is not in our catalog yet. Your scan was saved for admin review (submission #${submissionId}).` : `Your scan was saved for admin review (submission #${submissionId}).`, }; } async function createCardSubmission(userId, fields, candidateIds = []) { const ocrPayload = buildOcrPayload(fields); const result = await sql` INSERT INTO card_submissions ( user_id, ocr_text, ocr_confidence, scan_image_url, candidate_card_ids, ocr_payload, status ) VALUES ( ${userId}, ${fields.ocrData?.rawText || fields.name || null}, ${fields.ocrData?.confidence ?? null}, ${fields.scanImageUrl || null}, ${JSON.stringify(candidateIds)}, ${JSON.stringify(ocrPayload)}, 'pending' ) RETURNING id `; return result.rows[0].id; } /** * Match OCR fields against the global cards catalog. * Never INSERTs into cards — unknowns become card_submissions. */ export async function matchCardInCatalog({ userId, name, set, setCode, cardNumber, game, cardType, rarity, hp, manaCost, ocrData, scanImageUrl = null, }) { if (!name || typeof name !== 'string' || !name.trim()) { return { type: 'needs_input', card: null, matches: [], needsUserInput: true, message: 'Card name is required', }; } const trimmedName = name.trim(); let existingCard = null; if ((set || setCode) && cardNumber) { const exactResult = await sql` SELECT * FROM cards WHERE LOWER(name) = LOWER(${trimmedName}) AND (LOWER(set_name) = LOWER(${set || setCode}) OR LOWER(set_code) = LOWER(${setCode || set})) AND LOWER(card_number) = LOWER(${cardNumber}) LIMIT 1 `; if (exactResult.rows.length > 0) { existingCard = exactResult.rows[0]; } else { const setCandidates = await sql` SELECT * FROM cards WHERE LOWER(name) = LOWER(${trimmedName}) AND ( LOWER(set_name) = LOWER(${set || setCode}) OR LOWER(set_code) = LOWER(${setCode || set}) ) `; const normalizedMatch = findPrintingByCollectorNumber(setCandidates.rows, cardNumber); if (normalizedMatch) { existingCard = normalizedMatch; } else { const ambiguousMatches = setCandidates.rows.filter((row) => cardNumbersEquivalent(row.card_number, cardNumber) ); if (ambiguousMatches.length > 1) { return { type: 'disambiguation', card: null, matches: ambiguousMatches.map(mapCardRow), needsUserSelection: true, message: `Found ${ambiguousMatches.length} matches for "${trimmedName}" in that set. Select the correct printing.`, }; } return submitScanForReview( userId, { name: trimmedName, set, setCode, cardNumber, game, cardType, rarity, hp, manaCost, ocrData, scanImageUrl, }, [] ); } } } if (!existingCard && (set || setCode)) { const setResult = set ? await sql` SELECT * FROM cards WHERE LOWER(name) = LOWER(${trimmedName}) AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set})) LIMIT 5 ` : await sql` SELECT * FROM cards WHERE LOWER(name) = LOWER(${trimmedName}) AND LOWER(set_code) = LOWER(${setCode}) LIMIT 5 `; if (setResult.rows.length === 1) { existingCard = setResult.rows[0]; } else if (setResult.rows.length > 1) { return { type: 'disambiguation', card: null, matches: setResult.rows.map(mapCardRow), needsUserSelection: true, message: `Found ${setResult.rows.length} matches for "${trimmedName}" in that set. Select the correct printing.`, }; } } if (!existingCard) { const nameResult = game ? await sql` SELECT * FROM cards WHERE LOWER(name) = LOWER(${trimmedName}) ORDER BY CASE WHEN game = ${game} THEN 0 ELSE 1 END, set_name, card_number ` : await sql` SELECT * FROM cards WHERE LOWER(name) = LOWER(${trimmedName}) ORDER BY set_name, card_number `; if (nameResult.rows.length === 1) { existingCard = nameResult.rows[0]; } else if (nameResult.rows.length > 1) { return { type: 'disambiguation', card: null, matches: nameResult.rows.slice(0, 8).map(mapCardRow), needsUserSelection: true, message: `Found ${nameResult.rows.length} printings of "${trimmedName}". Select the correct card.`, }; } } if (!existingCard) { const fuzzyResult = await sql` SELECT * FROM cards WHERE LOWER(name) ILIKE LOWER(${`%${trimmedName}%`}) ORDER BY CASE WHEN LOWER(name) = LOWER(${trimmedName}) THEN 1 WHEN LOWER(name) LIKE LOWER(${trimmedName + '%'}) THEN 2 WHEN LOWER(name) LIKE LOWER(${'%' + trimmedName + '%'}) THEN 3 ELSE 4 END, CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END, LENGTH(name) LIMIT 5 `; if (fuzzyResult.rows.length > 0) { const exactFuzzyMatch = fuzzyResult.rows.find( (row) => row.name.toLowerCase() === trimmedName.toLowerCase() ); if (exactFuzzyMatch && ocrData?.confidence >= 80) { existingCard = exactFuzzyMatch; } else if (ocrData?.confidence < 80 && fuzzyResult.rows.length > 1) { return { type: 'disambiguation', card: null, matches: fuzzyResult.rows.map(mapCardRow), needsUserSelection: true, message: `Found ${fuzzyResult.rows.length} possible matches for "${trimmedName}". Please select the correct card.`, }; } else { existingCard = fuzzyResult.rows[0]; } } } if (existingCard) { const hasSpecificPrinting = Boolean((set || setCode) && cardNumber); if (hasSpecificPrinting) { if (!cardNumbersEquivalent(existingCard.card_number, cardNumber)) { const siblingIds = await sql` SELECT id FROM cards WHERE LOWER(name) = LOWER(${trimmedName}) `; return submitScanForReview( userId, { name: trimmedName, set, setCode, cardNumber, game, cardType, rarity, hp, manaCost, ocrData, scanImageUrl, }, siblingIds.rows.map((row) => row.id) ); } } else { const siblingsResult = await sql` SELECT * FROM cards WHERE LOWER(name) = LOWER(${trimmedName}) ORDER BY set_name, card_number `; if (siblingsResult.rows.length > 1) { const ordered = [ existingCard, ...siblingsResult.rows.filter((row) => row.id !== existingCard.id), ]; return { type: 'disambiguation', card: null, matches: ordered.slice(0, 8).map(mapCardRow), needsUserSelection: true, message: `Found ${siblingsResult.rows.length} printings of "${trimmedName}". Confirm the correct one.`, }; } } return { type: 'matched', card: existingCard, isExisting: true, message: `Found existing card: "${existingCard.name}"`, }; } const confidenceThreshold = 75; if (!ocrData || ocrData.confidence < confidenceThreshold) { return { type: 'needs_input', card: null, matches: [], needsUserInput: true, message: `Could not find card "${trimmedName}" in database and confidence is low (${ocrData?.confidence || 0}%). Please verify the card name and try again.`, }; } return submitScanForReview( userId, { name: trimmedName, set, setCode, cardNumber, game, cardType, rarity, hp, manaCost, ocrData, scanImageUrl, }, [] ); } export async function logScanAttempt({ userId, ocrText, ocrConfidence, layer = 2, matchedCardId = null, resultKind, latencyMs, }) { try { await sql` INSERT INTO scan_attempts ( user_id, ocr_text, ocr_confidence, layer, matched_card_id, result_kind, latency_ms ) VALUES ( ${userId}, ${ocrText || null}, ${ocrConfidence ?? null}, ${layer}, ${matchedCardId}, ${resultKind}, ${latencyMs ?? null} ) `; } catch (error) { // Telemetry must not block identification (e.g. migration not yet applied). console.error('[logScanAttempt]', error.message); } }