import { getUserFromRequest } from '../../../lib/permission-middleware'; import { checkScanRateLimit } from '../../../lib/rate-limit.js'; import { analyzeCardImage } from '../../../lib/scan-vision.js'; import { matchCardInCatalog, logScanAttempt } from '../../../lib/card-catalog-match.js'; function scanErrorResponse(error) { const msg = error?.message || ''; if (msg.includes('AI_GATEWAY_API_KEY is not configured')) { return { status: 503, body: { error: 'Card scanning is not configured on this server (missing AI_GATEWAY_API_KEY).', }, }; } if (msg.includes('Vision API error: 429')) { return { status: 502, body: { error: 'Vision service quota exceeded. Check AI Gateway billing or try again later.', }, }; } if (msg.includes('Vision API error: 403') || msg.includes('PERMISSION_DENIED')) { return { status: 502, body: { error: 'Vision service access denied. Check AI Gateway model access and API key.', }, }; } if (msg.includes('scan_attempts') || msg.includes('card_submissions')) { return { status: 503, body: { error: 'Scan database tables are missing. Run npm run migrate up on the deployment database.', }, }; } return { status: 500, body: { error: 'Internal server error' }, }; } function formatCardResponse(card, ocrResult) { 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, card_type: card.card_type, rarity: card.rarity, hp: card.power, mana_cost: card.mana_cost, image_url: card.image_url, ocr: { confidence: ocrResult.confidence, rawText: ocrResult.rawText, abilities: ocrResult.abilities, }, }; } function buildOcrPayload(ocrResult) { return { confidence: ocrResult.confidence, rawText: ocrResult.rawText, cardName: ocrResult.cardName, setName: ocrResult.setName, setCode: ocrResult.setCode, cardNumber: ocrResult.cardNumber, abilities: ocrResult.abilities, }; } export default async function handler(req, res) { if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }); } const startedAt = Date.now(); try { const user = await getUserFromRequest(req); if (!user) { return res.status(401).json({ error: 'Authentication required' }); } const { allowed, reset } = await checkScanRateLimit(req, user.userId); if (!allowed) { res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000)); return res.status(429).json({ error: 'Too many attempts. Try again later.' }); } const { imageData, game: preferredGame } = req.body || {}; if (!imageData || typeof imageData !== 'string') { return res.status(400).json({ error: 'imageData is required' }); } if (imageData.length > 6_000_000) { return res.status(400).json({ error: 'Image payload too large' }); } const ocrResult = await analyzeCardImage(imageData); const latencyMs = Date.now() - startedAt; const hasPartialCard = ocrResult.cardName && typeof ocrResult.cardName === 'string' && ocrResult.cardName.trim().length > 0; if ((!ocrResult.isCard || ocrResult.confidence <= 60) && !hasPartialCard) { await logScanAttempt({ userId: user.userId, ocrText: ocrResult.rawText, ocrConfidence: ocrResult.confidence, layer: 2, resultKind: 'not_a_card', latencyMs, }); return res.status(200).json({ isCard: false, confidence: ocrResult.confidence, reason: ocrResult.reason || 'No trading card detected', }); } const matchResult = await matchCardInCatalog({ userId: user.userId, name: ocrResult.cardName, set: ocrResult.setName, setCode: ocrResult.setCode, cardNumber: ocrResult.cardNumber, game: preferredGame || ocrResult.game, cardType: ocrResult.cardType, rarity: ocrResult.rarity, hp: ocrResult.hp, manaCost: ocrResult.manaCost, ocrData: { confidence: ocrResult.confidence, rawText: ocrResult.rawText, abilities: ocrResult.abilities, }, scanImageUrl: null, }); if (matchResult.type === 'matched') { await logScanAttempt({ userId: user.userId, ocrText: ocrResult.rawText, ocrConfidence: ocrResult.confidence, layer: 2, matchedCardId: matchResult.card.id, resultKind: 'matched', latencyMs, }); return res.status(200).json({ isCard: true, card: formatCardResponse(matchResult.card, ocrResult), isExisting: matchResult.isExisting, message: matchResult.message, }); } if (matchResult.type === 'disambiguation') { await logScanAttempt({ userId: user.userId, ocrText: ocrResult.rawText, ocrConfidence: ocrResult.confidence, layer: 2, resultKind: 'disambiguation', latencyMs, }); return res.status(200).json({ isCard: true, card: null, matches: matchResult.matches, needsUserSelection: true, ocr: buildOcrPayload(ocrResult), message: matchResult.message, }); } if (matchResult.type === 'submitted') { await logScanAttempt({ userId: user.userId, ocrText: ocrResult.rawText, ocrConfidence: ocrResult.confidence, layer: 2, resultKind: 'submitted', latencyMs, }); return res.status(200).json({ isCard: true, card: null, submissionId: matchResult.submissionId, needsReview: true, ocr: buildOcrPayload(ocrResult), message: matchResult.message, }); } await logScanAttempt({ userId: user.userId, ocrText: ocrResult.rawText, ocrConfidence: ocrResult.confidence, layer: 2, resultKind: 'needs_input', latencyMs, }); return res.status(200).json({ isCard: true, card: null, needsUserInput: true, ocr: buildOcrPayload(ocrResult), message: matchResult.message, }); } catch (error) { console.error('[POST /api/scan/identify]', error); const { status, body } = scanErrorResponse(error); return res.status(status).json(body); } }