From 0f1bf6a6b8e1e52d57be6e4ec4dfbf29cdd7e1e7 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 27 May 2026 13:55:31 -0500 Subject: [PATCH] feat(scanner): persist scan captures to Blob (Brief 3) Upload confirmed scan frames to Vercel Blob and store the URL on user_cards when routing to owned cards, completing the redesign-scanner-flow convoy. Co-authored-by: Cursor --- components/CameraScanner.js | 42 ++++++++++-- docs/SCHEMA_MAP.md | 1 + ...908094455_add-user-cards-scan-image-url.js | 28 ++++++++ pages/api/scan/upload-image.js | 67 +++++++++++++++++++ pages/api/user-cards.js | 23 +++++-- pages/scanner.js | 21 ++++-- 6 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 migrations/1779908094455_add-user-cards-scan-image-url.js create mode 100644 pages/api/scan/upload-image.js diff --git a/components/CameraScanner.js b/components/CameraScanner.js index bdabc0b..f8ae997 100644 --- a/components/CameraScanner.js +++ b/components/CameraScanner.js @@ -1,5 +1,29 @@ import { useState, useEffect, useRef } from 'react'; +async function uploadScanCapture(imageData) { + const response = await fetch('/api/scan/upload-image', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, + }, + body: JSON.stringify({ imageData }), + }); + + if (response.status === 429) { + console.warn('Scan image upload rate-limited'); + return null; + } + + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + throw new Error(errBody.error || 'Failed to upload scan image'); + } + + const result = await response.json(); + return result.url || null; +} + export default function CameraScanner({ onCardScanned, onError }) { const [isStreaming, setIsStreaming] = useState(false); const [isDetecting, setIsDetecting] = useState(false); @@ -245,7 +269,7 @@ export default function CameraScanner({ onCardScanned, onError }) { setTrackedCards(updatedCards); }; - const emitScannedCard = (cardTracker, imageData, finalCard, ocrMeta = {}) => { + const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => { cardTracker.status = 'scanned'; const originalTitle = document.title; @@ -254,6 +278,15 @@ export default function CameraScanner({ onCardScanned, onError }) { document.title = originalTitle; }, 3000); + let scanImageUrl = null; + if (imageData) { + try { + scanImageUrl = await uploadScanCapture(imageData); + } catch (uploadError) { + console.warn('Scan image upload failed:', uploadError); + } + } + onCardScanned({ name: finalCard.name, set: finalCard.set_name, @@ -268,16 +301,17 @@ export default function CameraScanner({ onCardScanned, onError }) { ocrText: ocrMeta.rawText, confidence: ocrMeta.confidence, capturedImage: imageData, + scanImageUrl, image_url: finalCard.image_url, databaseId: finalCard.id, isExisting: true, }); }; - const handleDisambiguationPick = (candidate) => { + const handleDisambiguationPick = async (candidate) => { if (!disambiguation) return; const { cardTracker, imageData, ocrMeta } = disambiguation; - emitScannedCard(cardTracker, imageData, candidate, ocrMeta); + await emitScannedCard(cardTracker, imageData, candidate, ocrMeta); setDisambiguation(null); disambiguationRefineRef.current = null; }; @@ -364,7 +398,7 @@ export default function CameraScanner({ onCardScanned, onError }) { if (result.card) { cardTracker.status = 'confirmed'; - emitScannedCard(cardTracker, imageData, result.card, ocrMeta); + await emitScannedCard(cardTracker, imageData, result.card, ocrMeta); return; } diff --git a/docs/SCHEMA_MAP.md b/docs/SCHEMA_MAP.md index c709251..e346099 100644 --- a/docs/SCHEMA_MAP.md +++ b/docs/SCHEMA_MAP.md @@ -98,6 +98,7 @@ | `condition` | `VARCHAR(50)` default `'NM'` | NM / LP / MP / HP / DMG | | `is_foil` | `BOOLEAN` default `false` | | | `notes` | `TEXT` | | +| `scan_image_url` | `TEXT` | Vercel Blob URL of scanner capture (`1779908094455_add-user-cards-scan-image-url`) | | | | **UNIQUE(user_id, card_id, is_foil)** | ### user_favorites diff --git a/migrations/1779908094455_add-user-cards-scan-image-url.js b/migrations/1779908094455_add-user-cards-scan-image-url.js new file mode 100644 index 0000000..4fd9662 --- /dev/null +++ b/migrations/1779908094455_add-user-cards-scan-image-url.js @@ -0,0 +1,28 @@ +/** + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +export const shorthands = undefined; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const up = (pgm) => { + pgm.sql(` + ALTER TABLE user_cards + ADD COLUMN IF NOT EXISTS scan_image_url TEXT + `); +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const down = (pgm) => { + pgm.sql(` + ALTER TABLE user_cards + DROP COLUMN IF EXISTS scan_image_url + `); +}; diff --git a/pages/api/scan/upload-image.js b/pages/api/scan/upload-image.js new file mode 100644 index 0000000..43e1f08 --- /dev/null +++ b/pages/api/scan/upload-image.js @@ -0,0 +1,67 @@ +import { randomUUID } from 'crypto'; +import { put } from '@vercel/blob'; +import { getUserFromRequest } from '../../../lib/permission-middleware'; +import { checkUploadRateLimit } from '../../../lib/rate-limit.js'; + +export const config = { + api: { + bodyParser: { + sizeLimit: '6mb', + }, + }, +}; + +function parseDataUrlImage(imageData) { + const match = imageData.match(/^data:(image\/(?:jpeg|jpg|png|webp));base64,(.+)$/i); + if (!match) return null; + + const contentType = match[1].toLowerCase() === 'image/jpg' ? 'image/jpeg' : match[1].toLowerCase(); + const buffer = Buffer.from(match[2], 'base64'); + return { contentType, buffer }; +} + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { allowed, reset } = await checkUploadRateLimit(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 } = req.body || {}; + if (!imageData || typeof imageData !== 'string' || imageData.length > 6_000_000) { + return res.status(400).json({ error: 'Valid imageData is required' }); + } + + const parsed = parseDataUrlImage(imageData); + if (!parsed) { + return res.status(400).json({ error: 'imageData must be a JPEG, PNG, or WebP data URL' }); + } + + if (parsed.buffer.length > 5 * 1024 * 1024) { + return res.status(400).json({ error: 'Image must be less than 5MB' }); + } + + const ext = parsed.contentType.split('/')[1] === 'jpeg' ? 'jpg' : parsed.contentType.split('/')[1]; + const filename = `scans/${user.userId}/${randomUUID()}.${ext}`; + + const blob = await put(filename, parsed.buffer, { + access: 'public', + contentType: parsed.contentType, + }); + + return res.status(200).json({ url: blob.url }); + } catch (error) { + console.error('[POST /api/scan/upload-image]', error); + return res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/pages/api/user-cards.js b/pages/api/user-cards.js index b1c4266..6739290 100644 --- a/pages/api/user-cards.js +++ b/pages/api/user-cards.js @@ -9,12 +9,23 @@ export default async function handler(req, res) { } if (req.method === 'POST') { - const { cardId, quantity = 1, condition = 'NM', is_foil = false } = req.body; + const { + cardId, + quantity = 1, + condition = 'NM', + is_foil = false, + scan_image_url: scanImageUrlRaw, + } = req.body; if (!cardId) { return res.status(400).json({ error: 'Card ID is required' }); } + const scanImageUrl = + typeof scanImageUrlRaw === 'string' && scanImageUrlRaw.trim().length > 0 + ? scanImageUrlRaw.trim() + : null; + // Check if user already owns this card const existingResult = await sql` SELECT * FROM user_cards @@ -22,18 +33,20 @@ export default async function handler(req, res) { `; if (existingResult.rows.length > 0) { - // Update quantity + // Update quantity; preserve existing scan image unless a new URL is supplied const newQuantity = existingResult.rows[0].quantity + quantity; await sql` UPDATE user_cards - SET quantity = ${newQuantity}, updated_at = CURRENT_TIMESTAMP + SET quantity = ${newQuantity}, + updated_at = CURRENT_TIMESTAMP, + scan_image_url = COALESCE(${scanImageUrl}, scan_image_url) WHERE user_id = ${user.userId} AND card_id = ${cardId} AND is_foil = ${is_foil} `; } else { // Insert new record await sql` - INSERT INTO user_cards (user_id, card_id, quantity, condition, is_foil) - VALUES (${user.userId}, ${cardId}, ${quantity}, ${condition}, ${is_foil}) + INSERT INTO user_cards (user_id, card_id, quantity, condition, is_foil, scan_image_url) + VALUES (${user.userId}, ${cardId}, ${quantity}, ${condition}, ${is_foil}, ${scanImageUrl}) `; } diff --git a/pages/scanner.js b/pages/scanner.js index 8174927..0d86490 100644 --- a/pages/scanner.js +++ b/pages/scanner.js @@ -158,6 +158,7 @@ export default function Scanner() { cardEntry = { ...existing, quantity: (existing.quantity || 1) + 1, + scanImageUrl: cardData.scanImageUrl || existing.scanImageUrl, timestamp: new Date().toISOString(), }; setScannedCards((prev) => { @@ -303,12 +304,20 @@ export default function Scanner() { ); }; - const buildCardPayload = (cardData) => ({ - cardId: cardData.databaseId, - quantity: cardData.quantity || 1, - condition: cardData.condition || 'NM', - is_foil: Boolean(cardData.isFoil), - }); + const buildCardPayload = (cardData) => { + const payload = { + cardId: cardData.databaseId, + quantity: cardData.quantity || 1, + condition: cardData.condition || 'NM', + is_foil: Boolean(cardData.isFoil), + }; + + if (cardData.scanImageUrl) { + payload.scan_image_url = cardData.scanImageUrl; + } + + return payload; + }; // Helper functions for API calls const addToOwnedCards = async (cardData) => {