import { getUserFromRequest } from '../../../lib/permission-middleware'; import { embedCardImage, EmbedApiError } from '../../../lib/card-embed.js'; import { matchVisualInCatalog, catalogHasEmbeddings } from '../../../lib/card-visual-match.js'; import { logScanAttempt } from '../../../lib/card-catalog-match.js'; function formatCardResponse(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, card_type: card.card_type, rarity: card.rarity, hp: card.power, mana_cost: card.mana_cost, image_url: card.image_url, visual: { similarity: card.sim, }, }; } 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 { imageData, game } = 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' }); } if (!(await catalogHasEmbeddings())) { const latencyMs = Date.now() - startedAt; await logScanAttempt({ userId: user.userId, ocrText: null, ocrConfidence: null, layer: 0, resultKind: 'escalate', latencyMs, }); return res.status(200).json({ layer: 0, escalate: true, reason: 'Catalog visual index is empty — run npm run backfill-embeddings', }); } const embedding = await embedCardImage(imageData); const matchResult = await matchVisualInCatalog({ embedding, game: game || null }); const latencyMs = Date.now() - startedAt; if (matchResult.type === 'escalate') { await logScanAttempt({ userId: user.userId, ocrText: null, ocrConfidence: null, layer: 0, resultKind: 'escalate', latencyMs, }); return res.status(200).json({ layer: 0, escalate: true, reason: matchResult.reason, }); } if (matchResult.type === 'matched') { await logScanAttempt({ userId: user.userId, ocrText: null, ocrConfidence: Math.round((matchResult.similarity || 0) * 100), layer: 0, matchedCardId: matchResult.card.id, resultKind: 'matched', latencyMs, }); return res.status(200).json({ layer: 0, escalate: false, isCard: true, card: formatCardResponse(matchResult.card), isExisting: true, message: matchResult.message, visual: { similarity: matchResult.similarity }, }); } await logScanAttempt({ userId: user.userId, ocrText: null, ocrConfidence: Math.round((matchResult.similarity || 0) * 100), layer: 0, resultKind: 'disambiguation', latencyMs, }); return res.status(200).json({ layer: 0, escalate: false, isCard: true, card: null, matches: matchResult.matches, needsUserSelection: true, message: matchResult.message, visual: { similarity: matchResult.similarity }, }); } catch (error) { console.error('[POST /api/scan/identify-by-image]', error); if (error instanceof EmbedApiError) { if (error.message.includes('AI_GATEWAY_API_KEY')) { return res.status(503).json({ error: 'Visual matching is not configured on this server (missing AI_GATEWAY_API_KEY).', }); } if (error.status === 429) { return res.status(502).json({ error: 'Embedding service quota exceeded. Try again later.', }); } } if (String(error.message).includes('vector') || String(error.message).includes('pgvector')) { return res.status(503).json({ error: 'Visual matching unavailable — run npm run migrate up (pgvector extension).', }); } return res.status(500).json({ error: 'Internal server error' }); } }