- {/* Header */}
-
+
+
- π€ OCR Settings
+ Card Scanner
-
- {/* Content */}
-
- {/* Service Selection */}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* OpenAI Settings */}
- {settings.service === 'openai' && (
-
-
-
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)'
- }}
- />
-
-
- )}
-
- {/* Gemini Settings */}
- {settings.service === 'gemini' && (
-
-
-
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)'
- }}
- />
-
-
- )}
-
- {/* Ollama Settings */}
- {settings.service === 'ollama' && (
-
-
-
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)'
- }}
- />
-
-
Install Ollama and run: ollama pull llava:latest
-
-
-
- )}
-
- {/* Test Result */}
- {testResult && (
-
-
- {testResult.message}
-
-
- )}
-
- {/* Actions */}
-
-
-
-
-
-
- {/* Usage Tips */}
-
-
π‘ Tips
-
- - β’ Puter.js offers free GPT-4o vision with no setup required
- - β’ OpenAI Vision API offers highest accuracy for card recognition
- - β’ Ollama is free and private but requires local setup
- - β’ Test your connection before scanning cards
- - β’ Settings are saved locally in your browser
-
-
-
+
+ Card identification runs on Deck Hearth's servers using Gemini Vision. No API keys
+ are required in your browser.
+
+
+ - Hold the card steady in the camera frame for best results.
+ - If multiple matches are found, you will be asked to pick the correct printing.
+ - Unknown cards are saved for admin review instead of being added to the global catalog.
+
+
);
-}
\ No newline at end of file
+}
diff --git a/docs/SCHEMA_MAP.md b/docs/SCHEMA_MAP.md
index b432281..2a0d1ab 100644
--- a/docs/SCHEMA_MAP.md
+++ b/docs/SCHEMA_MAP.md
@@ -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` | 0β100 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.
diff --git a/lib/ai-ocr.js b/lib/ai-ocr.js
deleted file mode 100644
index def1071..0000000
--- a/lib/ai-ocr.js
+++ /dev/null
@@ -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();
\ No newline at end of file
diff --git a/lib/card-catalog-match.js b/lib/card-catalog-match.js
new file mode 100644
index 0000000..0447b34
--- /dev/null
+++ b/lib/card-catalog-match.js
@@ -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}
+ )
+ `;
+}
diff --git a/lib/scan-gemini.js b/lib/scan-gemini.js
new file mode 100644
index 0000000..70ddafe
--- /dev/null
+++ b/lib/scan-gemini.js
@@ -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,
+ };
+}
diff --git a/migrations/1748365200000_add-scan-tables.js b/migrations/1748365200000_add-scan-tables.js
new file mode 100644
index 0000000..310ff72
--- /dev/null
+++ b/migrations/1748365200000_add-scan-tables.js
@@ -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');
+};
diff --git a/pages/admin/card-submissions.js b/pages/admin/card-submissions.js
new file mode 100644
index 0000000..ed988d4
--- /dev/null
+++ b/pages/admin/card-submissions.js
@@ -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 (
+
+
+ Card Scan Submissions
+
+
+ Review cards identified by the scanner that are not yet in the global catalog.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
Loadingβ¦
+ ) : submissions.length === 0 ? (
+
No pending submissions.
+ ) : (
+
+ {submissions.map((sub) => {
+ const payload = sub.ocr_payload || {};
+ return (
+ -
+
+
+
+ {payload.name || sub.ocr_text || 'Unknown card'}
+
+
+ {payload.game} Β· confidence {sub.ocr_confidence ?? 'β'}% Β· by {sub.submitter_email}
+
+
+
+ #{sub.id}
+
+
+ {sub.ocr_text && (
+
+ {sub.ocr_text}
+
+ )}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+export default function CardSubmissionsPage() {
+ return (
+
+ {(user) => (
+
+
+
+ )}
+
+ );
+}
diff --git a/pages/api/admin/card-submissions.js b/pages/api/admin/card-submissions.js
new file mode 100644
index 0000000..1cd2b86
--- /dev/null
+++ b/pages/api/admin/card-submissions.js
@@ -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' });
+ }
+}
diff --git a/pages/api/cards/find-or-create.js b/pages/api/cards/find-or-create.js
index 0b27687..17efbc4 100644
--- a/pages/api/cards/find-or-create.js
+++ b/pages/api/cards/find-or-create.js
@@ -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' });
}
-}
\ No newline at end of file
+}
diff --git a/pages/api/scan/identify.js b/pages/api/scan/identify.js
new file mode 100644
index 0000000..391c261
--- /dev/null
+++ b/pages/api/scan/identify.js
@@ -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' });
+ }
+}