feat(scanner): server-side scan pipeline (convoy #2) #35

Merged
varutasu merged 2 commits from convoy/server-side-scan-pipeline into main 2026-05-27 09:47:05 -04:00
12 changed files with 1111 additions and 1314 deletions
Showing only changes of commit b0f9a9ec71 - Show all commits

View file

@ -164,30 +164,25 @@ jobs:
done
exit 1
fi
# Grandfathered until server-side-scan-pipeline (#2) removes browser LLM clients.
GRANDFATHER=(
lib/ai-ocr.js
components/OCRSettings.js
)
AI_OCR_IMPORTS=$(grep -rEn 'from ['\''"].*ai-ocr|import.*ai-ocr' components/ 2>/dev/null || true)
if [ -n "$AI_OCR_IMPORTS" ]; then
echo "::error::Browser code must not import lib/ai-ocr — use POST /api/scan/identify instead."
echo "$AI_OCR_IMPORTS" | while IFS= read -r line; do
file=$(echo "$line" | cut -d: -f1)
lineno=$(echo "$line" | cut -d: -f2)
echo "::error file=${file},line=${lineno}::Remove ai-ocr import; call server-side scan API."
done
exit 1
fi
LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com'
FOUND=()
while IFS= read -r file; do
skip=false
for gf in "${GRANDFATHER[@]}"; do
if [ "$file" = "$gf" ]; then
skip=true
break
fi
done
if [ "$skip" = true ]; then
continue
fi
if grep -qE "$LLM_PATTERN" "$file" 2>/dev/null; then
FOUND+=("$file")
fi
done < <(find components lib pages -name '*.js' ! -path 'pages/api/*' 2>/dev/null || true)
if [ ${#FOUND[@]} -gt 0 ]; then
echo "::error::Client-side LLM API URLs must not appear outside pages/api/ (except grandfathered files pending server-side-scan-pipeline)."
echo "::error::Client-side LLM API URLs must not appear outside pages/api/."
for path in "${FOUND[@]}"; do
echo "::error file=${path}::Move LLM calls server-side or add to server-side-scan-pipeline removal list."
done

View file

@ -1,18 +1,9 @@
import { useState, useEffect, useRef } from 'react';
import { aiCardOCR, ollamaCardOCR, puterCardOCR, geminiCardOCR } from '../lib/ai-ocr';
export default function CameraScanner({ onCardScanned, onError }) {
const [isStreaming, setIsStreaming] = useState(false);
const [isDetecting, setIsDetecting] = useState(false);
const [scanningAnimation, setScanningAnimation] = useState(false);
const [scanResult, setScanResult] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [ocrSettings, setOcrSettings] = useState({
service: 'gemini',
openaiApiKey: '',
geminiApiKey: '',
ollamaUrl: 'http://localhost:11434'
});
const [disambiguation, setDisambiguation] = useState(null);
const videoRef = useRef(null);
const canvasRef = useRef(null);
@ -29,38 +20,6 @@ export default function CameraScanner({ onCardScanned, onError }) {
// Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
// Load OCR settings from localStorage only (never fetch server-side API keys)
useEffect(() => {
let settings = {
service: 'gemini',
openaiApiKey: '',
geminiApiKey: '',
ollamaUrl: 'http://localhost:11434'
};
const savedSettings = localStorage.getItem('ocrSettings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
settings = { ...settings, ...parsed };
} catch (error) {
console.error('Failed to load OCR settings:', error);
}
}
setOcrSettings(settings);
if (settings.openaiApiKey) {
aiCardOCR.setApiKey(settings.openaiApiKey);
}
if (settings.geminiApiKey) {
geminiCardOCR.setApiKey(settings.geminiApiKey);
}
if (settings.ollamaUrl) {
ollamaCardOCR.setBaseUrl(settings.ollamaUrl);
}
}, []);
// Configure canvas contexts for optimal performance
useEffect(() => {
if (canvasRef.current) {
@ -280,26 +239,102 @@ export default function CameraScanner({ onCardScanned, onError }) {
setTrackedCards(updatedCards);
};
// Quick card verification using AI
const emitScannedCard = (cardTracker, imageData, finalCard, ocrMeta = {}) => {
cardTracker.status = 'scanned';
const originalTitle = document.title;
document.title = `📸 ${finalCard.name} - Card Scanner`;
setTimeout(() => {
document.title = originalTitle;
}, 3000);
onCardScanned({
name: finalCard.name,
set: finalCard.set_name,
setCode: finalCard.set_code,
cardNumber: finalCard.card_number,
game: finalCard.game,
cardType: finalCard.card_type,
rarity: finalCard.rarity,
hp: finalCard.hp || ocrMeta.hp,
manaCost: finalCard.mana_cost || ocrMeta.manaCost,
abilities: ocrMeta.abilities || [],
ocrText: ocrMeta.rawText,
confidence: ocrMeta.confidence,
capturedImage: imageData,
image_url: finalCard.image_url,
databaseId: finalCard.id,
isExisting: true,
});
};
const handleDisambiguationPick = (candidate) => {
if (!disambiguation) return;
const { cardTracker, imageData, ocrMeta } = disambiguation;
emitScannedCard(cardTracker, imageData, candidate, ocrMeta);
setDisambiguation(null);
};
const processIdentifyResponse = async (cardTracker, imageData, result) => {
if (!result.isCard) {
cardTracker.status = 'negative';
onError?.(result.reason || 'No trading card detected');
return;
}
const ocrMeta = {
confidence: result.ocr?.confidence ?? result.card?.ocr?.confidence,
rawText: result.ocr?.rawText ?? result.card?.ocr?.rawText,
abilities: result.card?.ocr?.abilities || [],
hp: result.card?.hp,
manaCost: result.card?.mana_cost,
};
if (result.card) {
cardTracker.status = 'confirmed';
emitScannedCard(cardTracker, imageData, result.card, ocrMeta);
return;
}
if (result.needsUserSelection && result.matches?.length) {
cardTracker.status = 'confirmed';
setDisambiguation({
cardTracker,
imageData,
candidates: result.matches,
ocrMeta,
message: result.message,
});
return;
}
if (result.needsReview || result.needsUserInput) {
cardTracker.status = 'negative';
onError?.(result.message || 'Could not identify card — saved for review or retry.');
return;
}
cardTracker.status = 'negative';
onError?.('Could not identify card from scan.');
};
// Server-side card identification
const verifyCardShape = async (cardTracker) => {
if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return;
try {
console.log(`🔍 Verifying card ${cardTracker.id}...`);
cardTracker.scanAttempts++;
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
// Capture the specific region
const { x, y, width, height } = cardTracker.bounds;
const margin = 20; // Add some margin around the detected area
const margin = 20;
canvas.width = width + margin * 2;
canvas.height = height + margin * 2;
// Draw the card region with margin
ctx.drawImage(
video,
Math.max(0, x - margin), Math.max(0, y - margin),
@ -307,134 +342,35 @@ export default function CameraScanner({ onCardScanned, onError }) {
0, 0,
canvas.width, canvas.height
);
const imageData = canvas.toDataURL('image/jpeg', 0.8);
// Quick AI verification
let ocrResult;
console.log('🔍 Current OCR settings:', ocrSettings);
if (ocrSettings.service === 'puter') {
console.log('🎯 Using Puter.js (Free AI Vision)...');
ocrResult = await puterCardOCR.analyzeCard(imageData);
console.log('✅ Puter.js Vision result:', ocrResult);
} else if (ocrSettings.service === 'openai') {
console.log('🤖 Using OpenAI Vision API...');
ocrResult = await aiCardOCR.analyzeCard(imageData);
console.log('✅ OpenAI Vision result:', ocrResult);
} else if (ocrSettings.service === 'ollama') {
console.log('🦙 Using Ollama Vision...');
ocrResult = await ollamaCardOCR.analyzeCard(imageData);
console.log('✅ Ollama Vision result:', ocrResult);
} else if (ocrSettings.service === 'gemini') {
console.log('🤖 Using Gemini Vision API...');
ocrResult = await geminiCardOCR.analyzeCard(imageData);
console.log('✅ Gemini Vision result:', ocrResult);
} else {
throw new Error('No OCR service configured');
}
if (ocrResult.isCard && ocrResult.confidence > 60) {
// Confirmed as a card!
cardTracker.status = 'confirmed';
cardTracker.cardData = ocrResult;
console.log(`✅ Card ${cardTracker.id} confirmed: ${ocrResult.cardName}`);
// Process the card through database lookup
await processConfirmedCard(cardTracker, imageData);
} else {
// Not a card or low confidence
cardTracker.status = 'negative';
console.log(`❌ Card ${cardTracker.id} rejected: ${ocrResult.reason || 'Low confidence'}`);
}
} catch (error) {
console.error(`Error verifying card ${cardTracker.id}:`, error);
cardTracker.status = 'negative';
}
};
// Process confirmed card through database lookup
const processConfirmedCard = async (cardTracker, imageData) => {
try {
const ocrResult = cardTracker.cardData;
// Database lookup
console.log('🔍 Cross-referencing with database...');
const dbResponse = await fetch('/api/cards/find-or-create', {
const imageData = canvas.toDataURL('image/jpeg', 0.8);
const response = await fetch('/api/scan/identify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({
name: ocrResult.cardName.trim(),
set: ocrResult.setName,
setCode: ocrResult.setCode,
cardNumber: ocrResult.cardNumber,
game: ocrResult.game,
cardType: ocrResult.cardType,
rarity: ocrResult.rarity,
hp: ocrResult.hp,
manaCost: ocrResult.manaCost,
ocrData: {
confidence: ocrResult.confidence,
rawText: ocrResult.rawText,
abilities: ocrResult.abilities,
flavorText: ocrResult.flavorText,
artist: ocrResult.artist
}
})
body: JSON.stringify({ imageData }),
});
if (!dbResponse.ok) {
throw new Error(`Database lookup failed: ${dbResponse.status}`);
if (response.status === 429) {
onError?.('Too many scan attempts. Please wait a moment and try again.');
cardTracker.status = 'negative';
return;
}
const dbResult = await dbResponse.json();
// Handle successful card identification
if (dbResult.card) {
const finalCard = dbResult.card;
// Mark as scanned and send to parent
cardTracker.status = 'scanned';
// Update browser tab title with card name
const originalTitle = document.title;
document.title = `📸 ${finalCard.name} - Card Scanner`;
// Reset title after 3 seconds
setTimeout(() => {
document.title = originalTitle;
}, 3000);
onCardScanned({
name: finalCard.name,
set: finalCard.set_name,
setCode: finalCard.set_code,
cardNumber: finalCard.card_number,
game: finalCard.game,
cardType: finalCard.card_type,
rarity: finalCard.rarity,
hp: finalCard.hp || ocrResult.hp,
manaCost: finalCard.mana_cost || ocrResult.manaCost,
abilities: ocrResult.abilities,
ocrText: ocrResult.rawText,
confidence: ocrResult.confidence,
capturedImage: imageData,
image_url: finalCard.image_url,
databaseId: finalCard.id,
isExisting: dbResult.isExisting
});
console.log(`🎉 Card ${cardTracker.id} successfully scanned: ${finalCard.name}`);
if (!response.ok) {
throw new Error(`Scan identify failed: ${response.status}`);
}
const result = await response.json();
cardTracker.status = 'confirmed';
await processIdentifyResponse(cardTracker, imageData, result);
} catch (error) {
console.error(`Error processing card ${cardTracker.id}:`, error);
console.error(`Error verifying card ${cardTracker.id}:`, error);
cardTracker.status = 'negative';
onError?.(error.message || 'Scan failed');
}
};
@ -777,7 +713,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
</div>
<div className="flex items-center gap-2">
<span className="text-blue-500"></span>
<span>AI verification</span>
<span>Server identification</span>
</div>
<div className="flex items-center gap-2">
<span className="text-yellow-500"></span>
@ -790,6 +726,56 @@ export default function CameraScanner({ onCardScanned, onError }) {
</div>
</div>
)}
{disambiguation && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
<div
className="max-w-lg w-full rounded-xl border p-6 max-h-[80vh] overflow-y-auto"
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
role="dialog"
aria-modal="true"
aria-labelledby="disambiguation-title"
>
<h3 id="disambiguation-title" className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
Which card is this?
</h3>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
{disambiguation.message || 'Multiple matches found. Select the correct printing.'}
</p>
<div className="space-y-2">
{disambiguation.candidates.map((candidate) => (
<button
key={candidate.id}
type="button"
onClick={() => handleDisambiguationPick(candidate)}
className="w-full flex items-center gap-3 p-3 rounded-lg border text-left hover:opacity-90"
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--bg-tertiary)' }}
>
{candidate.image_url ? (
<img src={candidate.image_url} alt="" className="w-12 h-16 object-cover rounded" />
) : (
<div className="w-12 h-16 rounded flex items-center justify-center text-xs" style={{ backgroundColor: 'var(--bg-secondary)' }}>🃏</div>
)}
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>{candidate.name}</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{[candidate.set_name, candidate.set_code, candidate.card_number].filter(Boolean).join(' · ')}
</div>
</div>
</button>
))}
</div>
<button
type="button"
onClick={() => setDisambiguation(null)}
className="mt-4 w-full py-2 rounded-lg border text-sm"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
</div>
</div>
)}
</div>
);
}
}

View file

@ -1,444 +1,40 @@
import { useState, useEffect } from 'react';
import { aiCardOCR, ollamaCardOCR, geminiCardOCR } from '../lib/ai-ocr';
export default function OCRSettings({ isOpen, onClose }) {
const [settings, setSettings] = useState({
service: 'gemini', // Default to free Gemini
openaiApiKey: '',
geminiApiKey: '',
ollamaUrl: 'http://localhost:11434'
});
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
// Load settings from localStorage on mount (never fetch server-side API keys)
useEffect(() => {
const savedSettings = localStorage.getItem('ocrSettings');
let currentSettings = {
service: 'gemini',
openaiApiKey: '',
geminiApiKey: '',
ollamaUrl: 'http://localhost:11434'
};
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
currentSettings = { ...currentSettings, ...parsed };
} catch (error) {
console.error('Error loading OCR settings:', error);
}
}
setSettings(currentSettings);
}, []);
const saveSettings = () => {
try {
localStorage.setItem('ocrSettings', JSON.stringify(settings));
// Configure AI services
if (settings.openaiApiKey) {
aiCardOCR.setApiKey(settings.openaiApiKey);
}
if (settings.geminiApiKey) {
geminiCardOCR.setApiKey(settings.geminiApiKey);
}
if (settings.ollamaUrl) {
ollamaCardOCR.setBaseUrl(settings.ollamaUrl);
}
setTestResult({ type: 'success', message: 'Settings saved successfully!' });
setTimeout(() => setTestResult(null), 3000);
} catch (error) {
console.error('Error saving OCR settings:', error);
setTestResult({ type: 'error', message: 'Failed to save settings' });
}
};
const testConnection = async () => {
setIsTesting(true);
setTestResult(null);
try {
if (settings.service === 'puter') {
// Test Puter.js connection
try {
// Try to load Puter.js if not already loaded
if (typeof window !== 'undefined' && !window.puter) {
const script = document.createElement('script');
script.src = 'https://js.puter.com/v2/';
await new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
if (window.puter && window.puter.ai) {
setTestResult({ type: 'success', message: 'Puter.js loaded successfully! Ready for free AI vision.' });
} else {
setTestResult({ type: 'error', message: 'Failed to load Puter.js AI capabilities' });
}
} catch (error) {
setTestResult({ type: 'error', message: 'Could not connect to Puter.js service' });
}
} else if (settings.service === 'openai') {
if (!settings.openaiApiKey) {
setTestResult({ type: 'error', message: 'Please enter your OpenAI API key' });
return;
}
// Test with a simple request
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${settings.openaiApiKey}`,
}
});
if (response.ok) {
setTestResult({ type: 'success', message: 'OpenAI API connection successful!' });
} else {
const error = await response.json().catch(() => ({}));
setTestResult({
type: 'error',
message: `OpenAI API error: ${error.error?.message || response.statusText}`
});
}
} else if (settings.service === 'gemini') {
if (!settings.geminiApiKey) {
setTestResult({ type: 'error', message: 'Please enter your Gemini API key' });
return;
}
// Test with a simple request
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models', {
headers: {
'x-goog-api-key': settings.geminiApiKey,
}
});
if (response.ok) {
const data = await response.json();
const hasVisionModel = data.models?.some(model =>
model.name.includes('gemini') && model.supportedGenerationMethods?.includes('generateContent')
);
if (hasVisionModel) {
setTestResult({ type: 'success', message: 'Gemini API connection successful with vision support!' });
} else {
setTestResult({
type: 'warning',
message: 'Gemini connected but vision capabilities unclear.'
});
}
} else {
const error = await response.json().catch(() => ({}));
setTestResult({
type: 'error',
message: `Gemini API error: ${error.error?.message || response.statusText}`
});
}
} else if (settings.service === 'ollama') {
// Test Ollama connection
const response = await fetch(`${settings.ollamaUrl}/api/tags`);
if (response.ok) {
const data = await response.json();
const hasVisionModel = data.models?.some(model =>
model.name.includes('llava') || model.name.includes('vision')
);
if (hasVisionModel) {
setTestResult({ type: 'success', message: 'Ollama connection successful with vision models!' });
} else {
setTestResult({
type: 'warning',
message: 'Ollama connected but no vision models found. Install llava:latest for card scanning.'
});
}
} else {
setTestResult({ type: 'error', message: 'Could not connect to Ollama server' });
}
}
} catch (error) {
console.error('Connection test error:', error);
setTestResult({
type: 'error',
message: `Connection failed: ${error.message}`
});
} finally {
setIsTesting(false);
}
};
const handleInputChange = (field, value) => {
setSettings(prev => ({
...prev,
[field]: value
}));
setTestResult(null);
};
if (!isOpen) return null;
export default function OCRSettings({ onClose }) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="rounded-xl border max-w-md w-full max-h-[90vh] overflow-y-auto" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: 'var(--border)' }}>
<div
className="rounded-xl border max-w-md w-full p-6"
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
🤖 OCR Settings
Card Scanner
</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:opacity-70 transition-opacity"
className="p-2 rounded-lg hover:opacity-70"
style={{ color: 'var(--text-secondary)' }}
aria-label="Close"
>
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Service Selection */}
<div>
<label className="block text-sm font-medium mb-3" style={{ color: 'var(--text-primary)' }}>
OCR Service
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="service"
value="puter"
checked={settings.service === 'puter'}
onChange={(e) => handleInputChange('service', e.target.value)}
className="mr-3"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Puter.js</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Free GPT-4o vision - requires signing in to puter.com first
</div>
</div>
</label>
<label className="flex items-center">
<input
type="radio"
name="service"
value="openai"
checked={settings.service === 'openai'}
onChange={(e) => handleInputChange('service', e.target.value)}
className="mr-3"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>OpenAI Vision API</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
High accuracy, requires API key (~$0.01 per scan)
</div>
</div>
</label>
<label className="flex items-center">
<input
type="radio"
name="service"
value="gemini"
checked={settings.service === 'gemini'}
onChange={(e) => handleInputChange('service', e.target.value)}
className="mr-3"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Google Gemini (Recommended)</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Free tier available, high accuracy, requires API key
</div>
</div>
</label>
<label className="flex items-center">
<input
type="radio"
name="service"
value="ollama"
checked={settings.service === 'ollama'}
onChange={(e) => handleInputChange('service', e.target.value)}
className="mr-3"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<div>
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Ollama Local</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Private & free, requires local Ollama + LLaVA model
</div>
</div>
</label>
</div>
</div>
{/* OpenAI Settings */}
{settings.service === 'openai' && (
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
OpenAI API Key
</label>
<input
type="password"
placeholder="sk-..."
value={settings.openaiApiKey}
onChange={(e) => handleInputChange('openaiApiKey', e.target.value)}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
<div className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
Get your API key from{' '}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="underline hover:opacity-70"
style={{ color: 'var(--accent-ember)' }}
>
OpenAI Platform
</a>
</div>
</div>
)}
{/* Gemini Settings */}
{settings.service === 'gemini' && (
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Gemini API Key
</label>
<input
type="password"
placeholder="AIza..."
value={settings.geminiApiKey}
onChange={(e) => handleInputChange('geminiApiKey', e.target.value)}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
<div className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
Get your free API key from{' '}
<a
href="https://ai.google.dev/gemini-api/docs/api-key"
target="_blank"
rel="noopener noreferrer"
className="underline hover:opacity-70"
style={{ color: 'var(--accent-ember)' }}
>
Google AI Studio
</a>
</div>
</div>
)}
{/* Ollama Settings */}
{settings.service === 'ollama' && (
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Ollama Server URL
</label>
<input
type="url"
placeholder="http://localhost:11434"
value={settings.ollamaUrl}
onChange={(e) => handleInputChange('ollamaUrl', e.target.value)}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
<div className="mt-2 text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
<div>Install Ollama and run: <code className="px-1 py-0.5 rounded text-xs" style={{ backgroundColor: 'var(--bg-tertiary)' }}>ollama pull llava:latest</code></div>
<div>
Setup guide:{' '}
<a
href="https://ollama.ai"
target="_blank"
rel="noopener noreferrer"
className="underline hover:opacity-70"
style={{ color: 'var(--accent-ember)' }}
>
ollama.ai
</a>
</div>
</div>
</div>
)}
{/* Test Result */}
{testResult && (
<div className={`p-3 rounded-lg border ${
testResult.type === 'success' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' :
testResult.type === 'warning' ? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20' :
'border-red-500 bg-red-50 dark:bg-red-900/20'
}`}>
<div className={`text-sm ${
testResult.type === 'success' ? 'text-green-700 dark:text-green-300' :
testResult.type === 'warning' ? 'text-yellow-700 dark:text-yellow-300' :
'text-red-700 dark:text-red-300'
}`}>
{testResult.message}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={testConnection}
disabled={isTesting}
className="flex-1 px-4 py-2 rounded-lg border font-medium transition-all duration-200 hover:opacity-80 disabled:opacity-50"
style={{
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
{isTesting ? 'Testing...' : 'Test Connection'}
</button>
<button
onClick={saveSettings}
className="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-200 hover:opacity-90"
style={{
backgroundColor: 'var(--accent-ember)',
color: 'white'
}}
>
Save Settings
</button>
</div>
{/* Usage Tips */}
<div className="rounded-lg p-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>💡 Tips</div>
<ul className="text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
<li> Puter.js offers free GPT-4o vision with no setup required</li>
<li> OpenAI Vision API offers highest accuracy for card recognition</li>
<li> Ollama is free and private but requires local setup</li>
<li> Test your connection before scanning cards</li>
<li> Settings are saved locally in your browser</li>
</ul>
</div>
</div>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
Card identification runs on Deck Hearth&apos;s servers using Gemini Vision. No API keys
are required in your browser.
</p>
<ul className="text-sm space-y-2 mb-6" style={{ color: 'var(--text-secondary)' }}>
<li>Hold the card steady in the camera frame for best results.</li>
<li>If multiple matches are found, you will be asked to pick the correct printing.</li>
<li>Unknown cards are saved for admin review instead of being added to the global catalog.</li>
</ul>
<button
onClick={onClose}
className="w-full px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
>
Got it
</button>
</div>
</div>
);
}
}

View file

@ -26,6 +26,7 @@
| **Ownership** | `user_cards`, `user_favorites` | What a user owns / has favorited |
| **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing |
| **Decks** | `decks`, `deck_cards` | Playable deck definitions |
| **Scanning** | `card_submissions`, `scan_attempts` | Unknown-card review queue + scan telemetry |
| **Invitations** | `invitations` (referenced; verify) | Pending share requests |
## Tables
@ -166,6 +167,44 @@
| `quantity` | `INTEGER` default `1` | |
| | | **UNIQUE(deck_id, card_id)** |
### card_submissions
Added by `migrations/1748365200000_add-scan-tables.js` (server-side scan pipeline). Unknown high-confidence scans queue here for admin review instead of polluting `cards`.
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `user_id` | FK → `users` cascade | Submitter |
| `ocr_text` | `TEXT` | Raw OCR / vision text |
| `ocr_confidence` | `INTEGER` | 0100 from scan layer |
| `scan_image_url` | `TEXT` | Optional blob URL of capture |
| `candidate_card_ids` | `JSONB` default `'[]'` | Near-miss catalog IDs |
| `ocr_payload` | `JSONB` | Structured fields for admin promote |
| `status` | `VARCHAR(32)` default `'pending'` | `'pending' \| 'approved' \| 'rejected'` |
| `reviewed_by` | FK → `users` SET NULL | Admin reviewer |
| `promoted_card_id` | FK → `cards` SET NULL | Set on approve |
| `created_at`, `updated_at` | `TIMESTAMP` default now | |
Index: `idx_card_submissions_status (status, created_at DESC)`.
### scan_attempts
Per-scan telemetry for the identify pipeline (layer 2 = Gemini today).
| Column | Type | Notes |
| --- | --- | --- |
| `id` | `SERIAL PK` | |
| `user_id` | FK → `users` SET NULL | |
| `ocr_text` | `TEXT` | |
| `ocr_confidence` | `INTEGER` | |
| `layer` | `INTEGER` default `2` | OCR layer (1 = Tesseract future) |
| `matched_card_id` | FK → `cards` SET NULL | |
| `result_kind` | `VARCHAR(32)` | e.g. `'matched'`, `'disambiguation'`, `'submitted'`, `'not_a_card'` |
| `latency_ms` | `INTEGER` | End-to-end identify latency |
| `created_at` | `TIMESTAMP` default now | |
Index: `idx_scan_attempts_user_created (user_id, created_at DESC)`.
### user_settings (split from users.*; verify which is canonical)
Defined in `add-user-profile-fields.js`. Mirrors several `users.*` columns — there's redundancy that needs to be reconciled.

View file

@ -1,527 +0,0 @@
// AI OCR Service for Trading Card Recognition
export class AICardOCR {
constructor() {
this.apiKey = null;
this.baseUrl = 'https://api.openai.com/v1';
}
setApiKey(apiKey) {
this.apiKey = apiKey;
}
async analyzeCard(imageDataUrl) {
if (!this.apiKey) {
throw new Error('OpenAI API key not configured');
}
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
IMPORTANT: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
- Random objects, books, papers
- Screenshots of websites or apps
- Blurry or unclear images
- Non-card gaming items
If you detect a trading card, extract the following information in JSON format:
{
"isCard": true,
"cardName": "exact card name as printed",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities/attacks if visible"],
"flavorText": "flavor text if clearly readable",
"artist": "artist name if visible",
"confidence": 85,
"rawText": "all visible text on the card"
}
If NO trading card is detected, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Focus on accuracy over speed. Only extract data you can clearly read.`;
try {
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: prompt
},
{
type: 'image_url',
image_url: {
url: imageDataUrl,
detail: 'high'
}
}
]
}
],
max_tokens: 1000,
temperature: 0.1
})
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
// Parse JSON response
let result;
try {
// Clean up the response - remove markdown code blocks if present
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse OpenAI JSON response:', content);
// Fallback: try to extract card name from raw text
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n]+)/i);
result = {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: content,
reason: 'Failed to parse structured response'
};
}
// Ensure we have the required structure
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null
};
} catch (error) {
console.error('OpenAI Vision API error:', error);
throw error;
}
}
}
export class OllamaVisionOCR {
constructor() {
this.baseUrl = 'http://localhost:11434';
}
setBaseUrl(url) {
this.baseUrl = url;
}
async analyzeCard(imageDataUrl) {
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
IMPORTANT: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore random objects, books, papers, screenshots, or blurry images.
If you detect a trading card, extract this information in JSON format:
{
"isCard": true,
"cardName": "exact card name as printed",
"setName": "set name if visible",
"setCode": "set code if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"confidence": 85,
"rawText": "all visible text"
}
If NO trading card detected, respond: {"isCard": false, "confidence": 0, "reason": "No trading card detected"}`;
try {
// Convert data URL to base64
const base64Data = imageDataUrl.split(',')[1];
const response = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'llava:latest',
prompt: prompt,
images: [base64Data],
stream: false,
options: {
temperature: 0.1,
top_p: 0.9
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`);
}
const data = await response.json();
const content = data.response;
if (!content) {
throw new Error('No response from Ollama');
}
// Parse JSON response
let result;
try {
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse Ollama JSON response:', content);
result = {
isCard: false,
confidence: 0,
rawText: content,
reason: 'Failed to parse response'
};
}
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null
};
} catch (error) {
console.error('Ollama Vision API error:', error);
throw error;
}
}
}
export class PuterVisionOCR {
constructor() {
this.puterLoaded = false;
this.authFailed = false;
}
async loadPuterJS() {
if (this.puterLoaded || typeof window === 'undefined') return;
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://js.puter.com/v2/';
script.onload = () => {
this.puterLoaded = true;
resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
}
async analyzeCard(imageDataUrl) {
// If we've already failed auth, don't try again
if (this.authFailed) {
throw new Error('Puter.js authentication failed. Please use OpenAI or Ollama instead.');
}
try {
await this.loadPuterJS();
if (!window.puter) {
throw new Error('Puter.js not loaded');
}
// Check if user is authenticated with Puter
try {
await window.puter.auth.getUser();
} catch (authError) {
console.warn('Puter.js authentication required. Please sign in to Puter.com first.');
this.authFailed = true;
throw new Error('Puter.js requires authentication. Please use OpenAI or Ollama instead, or sign in to Puter.com first.');
}
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
- Random objects, books, papers, phone screens
- Screenshots of websites or digital interfaces
- Blurry, unclear, or dark images
- Non-card gaming items or accessories
If you detect a trading card, extract information in this JSON format:
{
"isCard": true,
"cardName": "exact card name as printed on the card",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, sorcery, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities or attacks if clearly readable"],
"confidence": 85,
"rawText": "all text visible on the card"
}
If NO trading card is clearly visible, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Be conservative - only extract data you can clearly read. Quality over quantity.`;
const response = await window.puter.ai.chat(prompt, imageDataUrl, {
model: "gpt-4o"
});
if (!response) {
throw new Error('No response from Puter.js');
}
// Parse JSON response
let result;
try {
// Clean up the response - remove markdown code blocks if present
const cleanContent = response.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse Puter JSON response:', response);
// Try to extract card name from raw response
const cardNameMatch = response.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
result = {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: response,
reason: 'Failed to parse structured response'
};
}
// Ensure we have the required structure
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || response,
reason: result.reason || null
};
} catch (error) {
console.error('Puter.js Vision API error:', error);
// Mark auth as failed if it's an auth-related error
if (error.message.includes('authentication') || error.message.includes('auth') || error.message.includes('401')) {
this.authFailed = true;
}
throw error;
}
}
}
// Gemini Vision OCR using Google's Gemini API
export class GeminiVisionOCR {
constructor() {
this.apiKey = null;
}
setApiKey(apiKey) {
this.apiKey = apiKey;
}
async analyzeCard(imageDataUrl) {
if (!this.apiKey) {
throw new Error('Gemini API key not configured');
}
// Convert data URL to base64
const base64Data = imageDataUrl.split(',')[1];
if (!base64Data) {
throw new Error('Invalid image data format');
}
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
- Random objects, books, papers, phone screens
- Screenshots of websites or digital interfaces
- Blurry, unclear, or dark images
- Non-card gaming items or accessories
If you detect a trading card, extract information in this JSON format:
{
"isCard": true,
"cardName": "exact card name as printed on the card",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, sorcery, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities or attacks if clearly readable"],
"confidence": 85,
"rawText": "all text visible on the card"
}
If NO trading card is clearly visible, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Be conservative - only extract data you can clearly read. Quality over quantity.`;
try {
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': this.apiKey
},
body: JSON.stringify({
contents: [{
parts: [
{ text: prompt },
{
inline_data: {
mime_type: 'image/jpeg',
data: base64Data
}
}
]
}],
generationConfig: {
thinkingConfig: {
thinkingBudget: 0 // Disable thinking for faster response
}
}
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Gemini API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`);
}
const data = await response.json();
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!content) {
throw new Error('No response from Gemini API');
}
// Parse JSON response
let result;
try {
// Clean up the response - remove markdown code blocks if present
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse Gemini JSON response:', content);
// Try to extract card name from raw response
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
result = {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: content,
reason: 'Failed to parse structured response'
};
}
// Ensure we have the required structure
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null
};
} catch (error) {
console.error('Gemini Vision API error:', error);
throw error;
}
}
}
// Export singleton instances
export const aiCardOCR = new AICardOCR();
export const ollamaCardOCR = new OllamaVisionOCR();
export const puterCardOCR = new PuterVisionOCR();
export const geminiCardOCR = new GeminiVisionOCR();

229
lib/card-catalog-match.js Normal file
View file

@ -0,0 +1,229 @@
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,
}) {
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}
)
`;
}

129
lib/scan-gemini.js Normal file
View file

@ -0,0 +1,129 @@
const GEMINI_MODEL =
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
const CARD_PROMPT = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore random objects, books, papers, phone screens, screenshots, blurry images, and non-card gaming items.
If you detect a trading card, extract information in this JSON format:
{
"isCard": true,
"cardName": "exact card name as printed on the card",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "mtg, pokemon, or lorcana (lowercase)",
"cardType": "creature, instant, sorcery, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities or attacks if clearly readable"],
"confidence": 85,
"rawText": "all text visible on the card"
}
If NO trading card is clearly visible, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Be conservative only extract data you can clearly read. Quality over quantity.`;
function parseGeminiJson(content) {
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
try {
return JSON.parse(cleanContent);
} catch {
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
return {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: content,
reason: 'Failed to parse structured response',
};
}
}
function normalizeGame(game) {
if (!game) return null;
const value = String(game).trim().toLowerCase();
if (value === 'mtg' || value.includes('magic')) return 'mtg';
if (value.includes('pokemon') || value.includes('pokémon')) return 'pokemon';
if (value.includes('lorcana')) return 'lorcana';
return value;
}
/**
* Server-side Gemini Vision analysis. Requires GEMINI_AI_API_KEY in env.
* @param {string} imageDataUrl - data:image/jpeg;base64,... capture from scanner
*/
export async function analyzeCardImage(imageDataUrl) {
const apiKey = process.env.GEMINI_AI_API_KEY;
if (!apiKey) {
throw new Error('GEMINI_AI_API_KEY is not configured on the server');
}
const base64Data = imageDataUrl.split(',')[1];
if (!base64Data) {
throw new Error('Invalid image data format');
}
const response = await fetch(GEMINI_MODEL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [{
parts: [
{ text: CARD_PROMPT },
{
inline_data: {
mime_type: 'image/jpeg',
data: base64Data,
},
},
],
}],
generationConfig: {
temperature: 0.1,
},
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
`Gemini API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`
);
}
const data = await response.json();
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!content) {
throw new Error('No response from Gemini API');
}
const result = parseGeminiJson(content);
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: normalizeGame(result.game),
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null,
};
}

View file

@ -0,0 +1,52 @@
/**
* card_submissions + scan_attempts for server-side scan pipeline.
*
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS card_submissions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ocr_text TEXT,
ocr_confidence INTEGER,
scan_image_url TEXT,
candidate_card_ids JSONB DEFAULT '[]',
ocr_payload JSONB,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
reviewed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
promoted_card_id INTEGER REFERENCES cards(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS scan_attempts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
ocr_text TEXT,
ocr_confidence INTEGER,
layer INTEGER NOT NULL DEFAULT 2,
matched_card_id INTEGER REFERENCES cards(id) ON DELETE SET NULL,
result_kind VARCHAR(32) NOT NULL,
latency_ms INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_card_submissions_status
ON card_submissions (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_scan_attempts_user_created
ON scan_attempts (user_id, created_at DESC);
`);
};
/**
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const down = (pgm) => {
throw new Error('Down migration not supported for add-scan-tables');
};

View file

@ -0,0 +1,145 @@
import { useState, useEffect } from 'react';
import Layout from '../../components/Layout';
import AdminProtected from '../../components/AdminProtected';
function CardSubmissionsAdmin() {
const [submissions, setSubmissions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [processingId, setProcessingId] = useState(null);
const loadSubmissions = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/admin/card-submissions?status=pending', {
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
});
if (!response.ok) {
throw new Error('Failed to load submissions');
}
const data = await response.json();
setSubmissions(data.submissions || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadSubmissions();
}, []);
const reviewSubmission = async (submissionId, action) => {
setProcessingId(submissionId);
try {
const response = await fetch('/api/admin/card-submissions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({ submissionId, action }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Review failed');
}
await loadSubmissions();
} catch (err) {
setError(err.message);
} finally {
setProcessingId(null);
}
};
return (
<div className="max-w-4xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Card Scan Submissions
</h1>
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
Review cards identified by the scanner that are not yet in the global catalog.
</p>
{error && (
<div className="mb-4 p-3 rounded-lg border border-red-500 text-sm text-red-600">
{error}
</div>
)}
{loading ? (
<p style={{ color: 'var(--text-secondary)' }}>Loading</p>
) : submissions.length === 0 ? (
<p style={{ color: 'var(--text-secondary)' }}>No pending submissions.</p>
) : (
<ul className="space-y-4">
{submissions.map((sub) => {
const payload = sub.ocr_payload || {};
return (
<li
key={sub.id}
className="p-4 rounded-xl border"
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}
>
<div className="flex justify-between gap-4 mb-2">
<div>
<div className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{payload.name || sub.ocr_text || 'Unknown card'}
</div>
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{payload.game} · confidence {sub.ocr_confidence ?? '—'}% · by {sub.submitter_email}
</div>
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
#{sub.id}
</div>
</div>
{sub.ocr_text && (
<p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{sub.ocr_text}
</p>
)}
<div className="flex gap-2">
<button
type="button"
disabled={processingId === sub.id}
onClick={() => reviewSubmission(sub.id, 'approve')}
className="px-3 py-1.5 rounded-lg text-sm font-medium disabled:opacity-50"
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
>
Approve
</button>
<button
type="button"
disabled={processingId === sub.id}
onClick={() => reviewSubmission(sub.id, 'reject')}
className="px-3 py-1.5 rounded-lg text-sm border disabled:opacity-50"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Reject
</button>
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
export default function CardSubmissionsPage() {
return (
<AdminProtected>
{(user) => (
<Layout user={user}>
<CardSubmissionsAdmin />
</Layout>
)}
</AdminProtected>
);
}

View file

@ -0,0 +1,104 @@
import { sql } from '@vercel/postgres';
import { getUserFromRequest } from '../../../lib/permission-middleware';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
if (req.method === 'GET') {
const { status = 'pending' } = req.query;
const result = await sql`
SELECT
cs.*,
u.email AS submitter_email
FROM card_submissions cs
JOIN users u ON cs.user_id = u.id
WHERE cs.status = ${status}
ORDER BY cs.created_at DESC
LIMIT 100
`;
return res.status(200).json({ submissions: result.rows });
}
if (req.method === 'POST') {
const { submissionId, action } = req.body;
if (!submissionId || !['approve', 'reject'].includes(action)) {
return res.status(400).json({ error: 'submissionId and action (approve|reject) are required' });
}
const submissionResult = await sql`
SELECT * FROM card_submissions WHERE id = ${submissionId} LIMIT 1
`;
if (submissionResult.rows.length === 0) {
return res.status(404).json({ error: 'Submission not found' });
}
const submission = submissionResult.rows[0];
if (submission.status !== 'pending') {
return res.status(400).json({ error: 'Submission has already been reviewed' });
}
if (action === 'reject') {
await sql`
UPDATE card_submissions
SET status = 'rejected', reviewed_by = ${user.userId}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${submissionId}
`;
return res.status(200).json({ message: 'Submission rejected' });
}
const payload = submission.ocr_payload || {};
const newCardResult = await sql`
INSERT INTO cards (
name, set_name, set_code, card_number, rarity, game,
mana_cost, card_type, oracle_text, power, verified
) VALUES (
${payload.name || 'Unknown'},
${payload.set || null},
${payload.setCode || null},
${payload.cardNumber || null},
${payload.rarity || null},
${payload.game || 'UNKNOWN'},
${payload.manaCost || null},
${payload.cardType || null},
${payload.rawText || submission.ocr_text || null},
${payload.hp || null},
${true}
)
RETURNING *
`;
const newCard = newCardResult.rows[0];
await sql`
UPDATE card_submissions
SET
status = 'approved',
reviewed_by = ${user.userId},
promoted_card_id = ${newCard.id},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${submissionId}
`;
return res.status(201).json({
message: 'Submission approved and card promoted to catalog',
card: newCard,
});
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('[admin/card-submissions]', error);
return res.status(500).json({ error: 'Internal server error' });
}
}

View file

@ -1,5 +1,5 @@
import { sql } from '@vercel/postgres';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { matchCardInCatalog } from '../../../lib/card-catalog-match.js';
export default async function handler(req, res) {
if (req.method !== 'POST') {
@ -22,191 +22,58 @@ export default async function handler(req, res) {
rarity,
hp,
manaCost,
ocrData
ocrData,
} = req.body;
if (!name) {
return res.status(400).json({ error: 'Card name is required' });
}
const matchResult = await matchCardInCatalog({
userId: user.userId,
name,
set,
setCode,
cardNumber,
game,
cardType,
rarity,
hp,
manaCost,
ocrData,
});
console.log(`🔍 Looking for card: "${name}" | Set: "${set || setCode}" | Number: "${cardNumber}" | Game: "${game}"`);
// First, try exact match by name, set, and card number (most specific)
let existingCard = null;
if ((set || setCode) && cardNumber) {
console.log('🎯 Trying exact match with card number...');
const exactQuery = sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${name})
AND (LOWER(set_name) = LOWER(${set || setCode}) OR LOWER(set_code) = LOWER(${setCode || set}))
AND LOWER(card_number) = LOWER(${cardNumber})
LIMIT 1
`;
const exactResult = await exactQuery;
if (exactResult.rows.length > 0) {
existingCard = exactResult.rows[0];
console.log('✅ Found exact match with card number:', existingCard.name);
}
}
// Second, try exact match by name and set (without card number)
if (!existingCard && (set || setCode)) {
console.log('🎯 Trying exact match by name and set...');
const setQuery = set ?
sql`SELECT * FROM cards WHERE LOWER(name) = LOWER(${name}) AND (LOWER(set_name) = LOWER(${set}) OR LOWER(set_code) = LOWER(${setCode || set})) LIMIT 1` :
sql`SELECT * FROM cards WHERE LOWER(name) = LOWER(${name}) AND LOWER(set_code) = LOWER(${setCode}) LIMIT 1`;
const setResult = await setQuery;
if (setResult.rows.length > 0) {
existingCard = setResult.rows[0];
console.log('✅ Found exact match by name and set:', existingCard.name);
}
}
// Third, try exact name match (any set)
if (!existingCard) {
console.log('🎯 Trying exact name match (any set)...');
const nameQuery = sql`
SELECT * FROM cards
WHERE LOWER(name) = LOWER(${name})
ORDER BY
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
created_at DESC
LIMIT 1
`;
const nameResult = await nameQuery;
if (nameResult.rows.length > 0) {
existingCard = nameResult.rows[0];
console.log('✅ Found exact name match:', existingCard.name);
}
}
// Fourth, try fuzzy name matching with game preference
if (!existingCard) {
console.log('🎯 Trying fuzzy name matching...');
const fuzzyResult = await sql`
SELECT * FROM cards
WHERE LOWER(name) ILIKE LOWER(${`%${name}%`})
ORDER BY
CASE
WHEN LOWER(name) = LOWER(${name}) THEN 1
WHEN LOWER(name) LIKE LOWER(${name + '%'}) THEN 2
WHEN LOWER(name) LIKE LOWER(${'%' + name + '%'}) THEN 3
ELSE 4
END,
CASE WHEN game = ${game || 'UNKNOWN'} THEN 1 ELSE 2 END,
LENGTH(name)
LIMIT 5
`;
if (fuzzyResult.rows.length > 0) {
console.log(`🔍 Found ${fuzzyResult.rows.length} fuzzy matches`);
// If we have high confidence and an exact match, use it
const exactFuzzyMatch = fuzzyResult.rows.find(row =>
row.name.toLowerCase() === name.toLowerCase()
);
if (exactFuzzyMatch && ocrData?.confidence >= 80) {
existingCard = exactFuzzyMatch;
console.log('✅ Using high-confidence fuzzy exact match:', existingCard.name);
} else if (ocrData?.confidence < 80 && fuzzyResult.rows.length > 1) {
// Low confidence with multiple matches - let user choose
console.log('⚠️ Multiple matches with low confidence - requiring user selection');
return res.status(200).json({
card: null,
matches: fuzzyResult.rows.map(card => ({
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
})),
needsUserSelection: true,
message: `Found ${fuzzyResult.rows.length} possible matches for "${name}". Please select the correct card.`
});
} else {
// Use the best match
existingCard = fuzzyResult.rows[0];
console.log('✅ Using best fuzzy match:', existingCard.name);
}
}
}
// If we found an existing card, return it
if (existingCard) {
console.log('🎉 Returning existing card:', existingCard.name);
if (matchResult.type === 'matched') {
return res.status(200).json({
card: existingCard,
isExisting: true,
message: `Found existing card: "${existingCard.name}"`
card: matchResult.card,
isExisting: matchResult.isExisting,
message: matchResult.message,
});
}
// If no existing card found, decide whether to create a new one
const confidenceThreshold = 75; // Increased threshold for better accuracy
if (!ocrData || ocrData.confidence < confidenceThreshold) {
console.log(`❌ No match found and confidence too low (${ocrData?.confidence || 0}% < ${confidenceThreshold}%)`);
if (matchResult.type === 'disambiguation') {
return res.status(200).json({
card: null,
matches: matchResult.matches,
needsUserSelection: true,
message: matchResult.message,
});
}
if (matchResult.type === 'submitted') {
return res.status(200).json({
card: null,
matches: [],
needsUserInput: true,
message: `Could not find card "${name}" in database and confidence is low (${ocrData?.confidence || 0}%). Please verify the card name and try again.`
submissionId: matchResult.submissionId,
needsReview: true,
message: matchResult.message,
});
}
// Create new card entry with enhanced data
console.log('🆕 Creating new card from OCR data...');
const newCardResult = await sql`
INSERT INTO cards (
name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url,
current_price, market_price, scryfall_id, verified
) VALUES (
${name.trim()},
${set || null},
${setCode || null},
${cardNumber || null},
${rarity || null},
${game || 'UNKNOWN'},
${manaCost || null},
${null}, -- cmc (calculated from mana cost)
${cardType || null},
${null}, -- colors (unknown from OCR)
${ocrData?.rawText || null}, -- Store OCR text in oracle_text temporarily
${hp || null}, -- power (HP for Pokemon)
${null}, -- toughness
${null}, -- image_url (to be fetched later)
${null}, -- stock_image_url
${null}, -- current_price
${null}, -- market_price
${null}, -- scryfall_id (to be populated later)
${false} -- not verified since it's from OCR
)
RETURNING *
`;
const newCard = newCardResult.rows[0];
// Log the OCR creation for potential review
console.log(`✅ Created new card from OCR: ${name} (${game}) - Confidence: ${ocrData?.confidence}%`);
return res.status(201).json({
card: newCard,
isExisting: false,
message: `Created new card "${name}" from scan data. This card may need verification.`
return res.status(200).json({
card: null,
matches: [],
needsUserInput: true,
message: matchResult.message,
});
} catch (error) {
console.error('Error in find-or-create card:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}
}

182
pages/api/scan/identify.js Normal file
View file

@ -0,0 +1,182 @@
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 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);
return res.status(500).json({ error: 'Internal server error' });
}
}