import { sql } from '@vercel/postgres'; 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, }; } 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]; } } 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 1 ` : await sql` SELECT * FROM cards WHERE LOWER(name) = LOWER(${trimmedName}) AND LOWER(set_code) = LOWER(${setCode}) LIMIT 1 `; if (setResult.rows.length > 0) { existingCard = setResult.rows[0]; } } if (!existingCard) { const nameResult = await sql` SELECT * FROM cards WHERE LOWER(name) = LOWER(${trimmedName}) ORDER BY CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END, created_at DESC LIMIT 1 `; if (nameResult.rows.length > 0) { existingCard = nameResult.rows[0]; } } 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) { 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.`, }; } const submissionId = await createCardSubmission( userId, { name: trimmedName, set, setCode, cardNumber, game, cardType, rarity, hp, manaCost, ocrData, scanImageUrl }, [] ); return { type: 'submitted', card: null, submissionId, needsReview: true, message: `Card "${trimmedName}" was not found in the catalog. Your scan was saved for admin review (submission #${submissionId}).`, }; } 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); } }