From e9b2da09a5b706fb976da305206ad660ea512b03 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 22 Jul 2025 08:01:20 -0500 Subject: [PATCH] Implement complete card scanning and collection flow - ready for local testing --- api/cards/find-or-create.js | 169 ++++++++++++++++++++++++ api/collections/add-card.js | 174 +++++++++++++++++++++++++ api/collections/index.js | 121 ++++++++++++++++++ src/pages/Scanner.tsx | 247 ++++++++++++++++++++++++++++++++++-- 4 files changed, 702 insertions(+), 9 deletions(-) create mode 100644 api/cards/find-or-create.js create mode 100644 api/collections/add-card.js create mode 100644 api/collections/index.js diff --git a/api/cards/find-or-create.js b/api/cards/find-or-create.js new file mode 100644 index 0000000..4e9a792 --- /dev/null +++ b/api/cards/find-or-create.js @@ -0,0 +1,169 @@ +const { Pool } = require('pg'); +const jwt = require('jsonwebtoken'); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, +}); + +// Middleware to verify user authentication +function verifyAuth(req) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new Error('No token provided'); + } + + const token = authHeader.substring(7); + const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production'; + + try { + const decoded = jwt.verify(token, jwtSecret); + return decoded; + } catch (error) { + throw new Error('Invalid token'); + } +} + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const client = await pool.connect(); + + try { + // Verify authentication + const user = verifyAuth(req); + + const { + name, + game, + setName = null, + setCode = null, + rarity = null, + cardType = null, + manaCost = null, + ocrConfidence = null, + ocrRawText = null, + imageUrl = null + } = req.body; + + // Validate required fields + if (!name || !game) { + return res.status(400).json({ + error: 'Card name and game are required' + }); + } + + // Try to find existing card first + let searchQuery = ` + SELECT id, name, set_name, set_code, rarity, game, card_type, mana_cost, + current_price, stock_image_url, image_url, verified + FROM cards + WHERE LOWER(name) = LOWER($1) AND UPPER(game) = UPPER($2) + `; + + let searchParams = [name.trim(), game.trim()]; + + // If set name is provided, try to match more specifically + if (setName) { + searchQuery += ` AND (set_name IS NULL OR LOWER(set_name) = LOWER($3))`; + searchParams.push(setName.trim()); + } + + searchQuery += ` ORDER BY verified DESC, created_at DESC LIMIT 1`; + + const existingCard = await client.query(searchQuery, searchParams); + + if (existingCard.rows.length > 0) { + // Card found, return it + const card = existingCard.rows[0]; + + res.status(200).json({ + success: true, + found: true, + message: 'Card found in database', + card: { + id: card.id, + name: card.name, + setName: card.set_name, + setCode: card.set_code, + rarity: card.rarity, + game: card.game, + cardType: card.card_type, + manaCost: card.mana_cost, + currentPrice: card.current_price, + stockImageUrl: card.stock_image_url, + imageUrl: card.image_url, + verified: card.verified + } + }); + } else { + // Card not found, create new one + const insertQuery = ` + INSERT INTO cards ( + name, game, set_name, set_code, rarity, card_type, mana_cost, + ocr_confidence, ocr_raw_text, image_url, verified, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + RETURNING id, name, set_name, set_code, rarity, game, card_type, mana_cost, + ocr_confidence, ocr_raw_text, image_url, verified, created_at + `; + + const insertParams = [ + name.trim(), + game.trim().toUpperCase(), + setName?.trim() || null, + setCode?.trim() || null, + rarity?.trim() || null, + cardType?.trim() || null, + manaCost?.trim() || null, + ocrConfidence || null, + ocrRawText?.trim() || null, + imageUrl?.trim() || null, + false // New cards from OCR are unverified by default + ]; + + const newCard = await client.query(insertQuery, insertParams); + const card = newCard.rows[0]; + + res.status(201).json({ + success: true, + found: false, + message: 'New card created from scan data', + card: { + id: card.id, + name: card.name, + setName: card.set_name, + setCode: card.set_code, + rarity: card.rarity, + game: card.game, + cardType: card.card_type, + manaCost: card.mana_cost, + currentPrice: null, + stockImageUrl: null, + imageUrl: card.image_url, + verified: card.verified, + ocrConfidence: card.ocr_confidence, + ocrRawText: card.ocr_raw_text, + createdAt: card.created_at + } + }); + } + + } catch (error) { + console.error('Find or create card error:', error); + + if (error.message === 'No token provided' || error.message === 'Invalid token') { + res.status(401).json({ error: error.message }); + } else { + res.status(500).json({ + error: 'Internal server error', + details: error.message + }); + } + } finally { + client.release(); + } +} \ No newline at end of file diff --git a/api/collections/add-card.js b/api/collections/add-card.js new file mode 100644 index 0000000..1fd100d --- /dev/null +++ b/api/collections/add-card.js @@ -0,0 +1,174 @@ +const { Pool } = require('pg'); +const jwt = require('jsonwebtoken'); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, +}); + +// Middleware to verify user authentication +function verifyAuth(req) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new Error('No token provided'); + } + + const token = authHeader.substring(7); + const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production'; + + try { + const decoded = jwt.verify(token, jwtSecret); + return decoded; + } catch (error) { + throw new Error('Invalid token'); + } +} + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const client = await pool.connect(); + + try { + // Verify authentication + const user = verifyAuth(req); + + const { + collectionId, + cardId, + quantity = 1, + condition = 'near-mint', + notes = null, + purchasePrice = null + } = req.body; + + // Validate required fields + if (!collectionId || !cardId) { + return res.status(400).json({ + error: 'Collection ID and Card ID are required' + }); + } + + // Verify collection ownership + const collectionCheck = await client.query( + 'SELECT id, name FROM user_collections WHERE id = $1 AND user_id = $2', + [collectionId, user.userId] + ); + + if (collectionCheck.rows.length === 0) { + return res.status(404).json({ + error: 'Collection not found or access denied' + }); + } + + // Verify card exists + const cardCheck = await client.query( + 'SELECT id, name, game, rarity, current_price FROM cards WHERE id = $1', + [cardId] + ); + + if (cardCheck.rows.length === 0) { + return res.status(404).json({ + error: 'Card not found' + }); + } + + const card = cardCheck.rows[0]; + const collection = collectionCheck.rows[0]; + + // Check if card already exists in collection with same condition + const existingCard = await client.query(` + SELECT id, quantity + FROM collection_cards + WHERE collection_id = $1 AND card_id = $2 AND condition = $3 + `, [collectionId, cardId, condition]); + + let result; + + if (existingCard.rows.length > 0) { + // Update existing entry - increase quantity + const newQuantity = existingCard.rows[0].quantity + quantity; + + await client.query(` + UPDATE collection_cards + SET quantity = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 + `, [newQuantity, existingCard.rows[0].id]); + + result = { + action: 'updated', + previousQuantity: existingCard.rows[0].quantity, + newQuantity: newQuantity + }; + } else { + // Create new collection card entry + const insertResult = await client.query(` + INSERT INTO collection_cards ( + collection_id, card_id, quantity, condition, notes, purchase_price + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, added_at + `, [collectionId, cardId, quantity, condition, notes, purchasePrice]); + + result = { + action: 'added', + collectionCardId: insertResult.rows[0].id, + addedAt: insertResult.rows[0].added_at + }; + } + + // Get updated collection stats + const statsQuery = await client.query(` + SELECT + COUNT(cc.id) as total_entries, + COALESCE(SUM(cc.quantity), 0) as total_cards, + COALESCE(SUM(cc.quantity * COALESCE(c.current_price, 0)), 0) as total_value + FROM collection_cards cc + LEFT JOIN cards c ON cc.card_id = c.id + WHERE cc.collection_id = $1 + `, [collectionId]); + + const stats = statsQuery.rows[0]; + + res.status(200).json({ + success: true, + message: `Card ${result.action} successfully`, + result: { + ...result, + card: { + id: card.id, + name: card.name, + game: card.game, + rarity: card.rarity, + currentPrice: card.current_price + }, + collection: { + id: collection.id, + name: collection.name + }, + quantity: quantity, + condition: condition, + collectionStats: { + totalEntries: parseInt(stats.total_entries), + totalCards: parseInt(stats.total_cards), + totalValue: parseFloat(stats.total_value) + } + } + }); + + } catch (error) { + console.error('Add card to collection error:', error); + + if (error.message === 'No token provided' || error.message === 'Invalid token') { + res.status(401).json({ error: error.message }); + } else { + res.status(500).json({ + error: 'Internal server error', + details: error.message + }); + } + } finally { + client.release(); + } +} \ No newline at end of file diff --git a/api/collections/index.js b/api/collections/index.js new file mode 100644 index 0000000..73397e9 --- /dev/null +++ b/api/collections/index.js @@ -0,0 +1,121 @@ +const { Pool } = require('pg'); +const jwt = require('jsonwebtoken'); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, +}); + +// Middleware to verify user authentication +function verifyAuth(req) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new Error('No token provided'); + } + + const token = authHeader.substring(7); + const jwtSecret = process.env.JWT_SECRET || 'fallback-secret-change-in-production'; + + try { + const decoded = jwt.verify(token, jwtSecret); + return decoded; + } catch (error) { + throw new Error('Invalid token'); + } +} + +export default async function handler(req, res) { + const client = await pool.connect(); + + try { + // Verify authentication + const user = verifyAuth(req); + + if (req.method === 'GET') { + // Get user's collections + const collectionsQuery = ` + SELECT + uc.id, uc.name, uc.description, uc.is_public, uc.created_at, uc.updated_at, + COUNT(cc.id) as total_cards, + COALESCE(SUM(cc.quantity * COALESCE(c.current_price, 0)), 0) as total_value + FROM user_collections uc + LEFT JOIN collection_cards cc ON uc.id = cc.collection_id + LEFT JOIN cards c ON cc.card_id = c.id + WHERE uc.user_id = $1 + GROUP BY uc.id, uc.name, uc.description, uc.is_public, uc.created_at, uc.updated_at + ORDER BY uc.created_at DESC + `; + + const collections = await client.query(collectionsQuery, [user.userId]); + + res.status(200).json({ + success: true, + collections: collections.rows.map(collection => ({ + id: collection.id, + name: collection.name, + description: collection.description, + isPublic: collection.is_public, + totalCards: parseInt(collection.total_cards) || 0, + totalValue: parseFloat(collection.total_value) || 0, + createdAt: collection.created_at, + updatedAt: collection.updated_at + })) + }); + + } else if (req.method === 'POST') { + // Create new collection + const { name, description, isPublic = false } = req.body; + + if (!name || name.trim().length === 0) { + return res.status(400).json({ error: 'Collection name is required' }); + } + + const insertQuery = ` + INSERT INTO user_collections (user_id, name, description, is_public) + VALUES ($1, $2, $3, $4) + RETURNING id, name, description, is_public, created_at, updated_at + `; + + const result = await client.query(insertQuery, [ + user.userId, + name.trim(), + description?.trim() || null, + isPublic + ]); + + const newCollection = result.rows[0]; + + res.status(201).json({ + success: true, + message: 'Collection created successfully', + collection: { + id: newCollection.id, + name: newCollection.name, + description: newCollection.description, + isPublic: newCollection.is_public, + totalCards: 0, + totalValue: 0, + createdAt: newCollection.created_at, + updatedAt: newCollection.updated_at + } + }); + + } else { + res.status(405).json({ error: 'Method not allowed' }); + } + + } catch (error) { + console.error('Collections API error:', error); + + if (error.message === 'No token provided' || error.message === 'Invalid token') { + res.status(401).json({ error: error.message }); + } else { + res.status(500).json({ + error: 'Internal server error', + details: error.message + }); + } + } finally { + client.release(); + } +} \ No newline at end of file diff --git a/src/pages/Scanner.tsx b/src/pages/Scanner.tsx index 3842ae2..e73ef03 100644 --- a/src/pages/Scanner.tsx +++ b/src/pages/Scanner.tsx @@ -1,8 +1,9 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import CameraScanner from '../components/CameraScanner'; import GlowingCard from '../components/GlowingCard'; import CardImageDisplay from '../components/CardImageDisplay'; import { cardMatcher } from '../services/cardMatcher'; +import { useAuth } from '../contexts/AuthContext'; interface ScannedCard { id: string; @@ -14,11 +15,91 @@ interface ScannedCard { timestamp: number; } +interface Collection { + id: string; + name: string; + description?: string; + totalCards: number; + totalValue: number; +} + const Scanner: React.FC = () => { + const { user } = useAuth(); const [isDragging, setIsDragging] = useState(false); const [scannedCards, setScannedCards] = useState([]); const [isProcessingMatch, setIsProcessingMatch] = useState(false); const [error, setError] = useState(null); + const [collections, setCollections] = useState([]); + const [selectedCollectionId, setSelectedCollectionId] = useState(null); + const [isLoadingCollections, setIsLoadingCollections] = useState(false); + const [isAddingToCollection, setIsAddingToCollection] = useState(false); + + // Load user's collections on component mount + useEffect(() => { + if (user) { + loadCollections(); + } + }, [user]); + + const loadCollections = async () => { + setIsLoadingCollections(true); + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/collections', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + const data = await response.json(); + setCollections(data.collections || []); + + // Auto-select first collection if available + if (data.collections && data.collections.length > 0) { + setSelectedCollectionId(data.collections[0].id); + } + } else { + console.error('Failed to load collections'); + } + } catch (error) { + console.error('Error loading collections:', error); + } finally { + setIsLoadingCollections(false); + } + }; + + const createNewCollection = async (name: string, description?: string) => { + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/collections', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: name.trim(), + description: description?.trim() || null, + isPublic: false + }) + }); + + if (response.ok) { + const data = await response.json(); + const newCollection = data.collection; + setCollections(prev => [newCollection, ...prev]); + setSelectedCollectionId(newCollection.id); + return newCollection; + } else { + throw new Error('Failed to create collection'); + } + } catch (error) { + console.error('Error creating collection:', error); + throw error; + } + }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -90,14 +171,92 @@ const Scanner: React.FC = () => { const scannedCard = scannedCards.find(c => c.id === scannedCardId); if (!scannedCard || !scannedCard.selectedMatch) return; + if (!selectedCollectionId) { + setError('Please select a collection first.'); + return; + } + + setIsAddingToCollection(true); try { - // TODO: Implement collection service - console.log('Adding to collection:', scannedCard.selectedMatch); - // For now, just remove from scan results - removeScannedCard(scannedCardId); - } catch (err) { + const token = localStorage.getItem('token'); + + // First, ensure the card exists in our database + let cardId = scannedCard.selectedMatch.id; + + if (!cardId) { + // Need to find or create the card + const findOrCreateResponse = await fetch('/api/cards/find-or-create', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: scannedCard.selectedMatch.name, + game: scannedCard.selectedMatch.game || 'MTG', + setName: scannedCard.selectedMatch.set_name, + setCode: scannedCard.selectedMatch.set_code, + rarity: scannedCard.selectedMatch.rarity, + cardType: scannedCard.selectedMatch.card_type, + manaCost: scannedCard.selectedMatch.mana_cost, + ocrConfidence: scannedCard.ocrConfidence, + ocrRawText: scannedCard.ocrText + }) + }); + + if (findOrCreateResponse.ok) { + const cardData = await findOrCreateResponse.json(); + cardId = cardData.card.id; + } else { + throw new Error('Failed to find or create card in database'); + } + } + + // Now add the card to the collection + const addResponse = await fetch('/api/collections/add-card', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + collectionId: selectedCollectionId, + cardId: cardId, + quantity: 1, + condition: 'near-mint', + notes: `Added via OCR scan (${Math.round(scannedCard.ocrConfidence)}% confidence)` + }) + }); + + if (addResponse.ok) { + const result = await addResponse.json(); + + // Update collection stats + setCollections(prev => prev.map(collection => + collection.id === selectedCollectionId + ? { + ...collection, + totalCards: result.result.collectionStats.totalCards, + totalValue: result.result.collectionStats.totalValue + } + : collection + )); + + // Show success message and remove from scan results + setError(null); + removeScannedCard(scannedCardId); + + // You could show a success toast here instead + console.log(`✅ ${result.message}:`, result.result.card.name); + } else { + const errorData = await addResponse.json(); + throw new Error(errorData.error || 'Failed to add card to collection'); + } + } catch (err: any) { console.error('Failed to add to collection:', err); - setError('Failed to add card to collection.'); + setError(`Failed to add card to collection: ${err.message}`); + } finally { + setIsAddingToCollection(false); } }; @@ -135,6 +294,66 @@ const Scanner: React.FC = () => { )} + {/* Collection Selection */} + {user && ( +
+

📚 Collection Selection

+ + {isLoadingCollections ? ( +
+
+ Loading collections... +
+ ) : collections.length > 0 ? ( +
+
+ + +
+ + {selectedCollectionId && ( +
+ ✅ Cards will be added to: {collections.find(c => c.id === selectedCollectionId)?.name} +
+ )} +
+ ) : ( +
+
+ You don't have any collections yet. Create one to start adding scanned cards! +
+ +
+ )} +
+ )} + {/* Camera Scanner */}

📸 Live Camera Scanner

@@ -286,9 +505,19 @@ const Scanner: React.FC = () => {