deckhearth/pages/api/scan/identify.js
varutasu be5dd8027f
fix(scanner): gemini-2.5-flash model + actionable scan error messages (#37)
Use the same vision model as the deleted browser client, surface Gemini
quota/denial/migration failures as 502/503 with readable text, and stop
scan_attempts telemetry from blocking identification.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:42:37 -05:00

228 lines
6.2 KiB
JavaScript

import { getUserFromRequest } from '../../../lib/permission-middleware';
import { checkScanRateLimit } from '../../../lib/rate-limit.js';
import { analyzeCardImage } from '../../../lib/scan-gemini.js';
import { matchCardInCatalog, logScanAttempt } from '../../../lib/card-catalog-match.js';
function scanErrorResponse(error) {
const msg = error?.message || '';
if (msg.includes('GEMINI_AI_API_KEY is not configured')) {
return {
status: 503,
body: {
error: 'Card scanning is not configured on this server (missing GEMINI_AI_API_KEY).',
},
};
}
if (msg.includes('Gemini API error: 429')) {
return {
status: 502,
body: {
error: 'Vision service quota exceeded. Check Gemini API billing or try again later.',
},
};
}
if (msg.includes('Gemini API error: 403') || msg.includes('PERMISSION_DENIED')) {
return {
status: 502,
body: {
error: 'Vision service access denied. Regenerate the Gemini API key in Google AI Studio.',
},
};
}
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,
},
};
}
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;
if (!ocrResult.isCard || ocrResult.confidence <= 60) {
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: {
confidence: ocrResult.confidence,
rawText: ocrResult.rawText,
cardName: ocrResult.cardName,
},
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: {
confidence: ocrResult.confidence,
rawText: ocrResult.rawText,
cardName: ocrResult.cardName,
},
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: {
confidence: ocrResult.confidence,
rawText: ocrResult.rawText,
cardName: ocrResult.cardName,
},
message: matchResult.message,
});
} catch (error) {
console.error('[POST /api/scan/identify]', error);
const { status, body } = scanErrorResponse(error);
return res.status(status).json(body);
}
}