feat(scanner): persist scan captures to Blob (Brief 3) (#44)

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 <cursoragent@cursor.com>
This commit is contained in:
varutasu 2026-05-27 13:57:26 -05:00 committed by GitHub
parent 24c9da4095
commit 673af83519
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 167 additions and 15 deletions

View file

@ -1,5 +1,29 @@
import { useState, useEffect, useRef } from 'react'; 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 }) { export default function CameraScanner({ onCardScanned, onError }) {
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
@ -245,7 +269,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
setTrackedCards(updatedCards); setTrackedCards(updatedCards);
}; };
const emitScannedCard = (cardTracker, imageData, finalCard, ocrMeta = {}) => { const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => {
cardTracker.status = 'scanned'; cardTracker.status = 'scanned';
const originalTitle = document.title; const originalTitle = document.title;
@ -254,6 +278,15 @@ export default function CameraScanner({ onCardScanned, onError }) {
document.title = originalTitle; document.title = originalTitle;
}, 3000); }, 3000);
let scanImageUrl = null;
if (imageData) {
try {
scanImageUrl = await uploadScanCapture(imageData);
} catch (uploadError) {
console.warn('Scan image upload failed:', uploadError);
}
}
onCardScanned({ onCardScanned({
name: finalCard.name, name: finalCard.name,
set: finalCard.set_name, set: finalCard.set_name,
@ -268,16 +301,17 @@ export default function CameraScanner({ onCardScanned, onError }) {
ocrText: ocrMeta.rawText, ocrText: ocrMeta.rawText,
confidence: ocrMeta.confidence, confidence: ocrMeta.confidence,
capturedImage: imageData, capturedImage: imageData,
scanImageUrl,
image_url: finalCard.image_url, image_url: finalCard.image_url,
databaseId: finalCard.id, databaseId: finalCard.id,
isExisting: true, isExisting: true,
}); });
}; };
const handleDisambiguationPick = (candidate) => { const handleDisambiguationPick = async (candidate) => {
if (!disambiguation) return; if (!disambiguation) return;
const { cardTracker, imageData, ocrMeta } = disambiguation; const { cardTracker, imageData, ocrMeta } = disambiguation;
emitScannedCard(cardTracker, imageData, candidate, ocrMeta); await emitScannedCard(cardTracker, imageData, candidate, ocrMeta);
setDisambiguation(null); setDisambiguation(null);
disambiguationRefineRef.current = null; disambiguationRefineRef.current = null;
}; };
@ -364,7 +398,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
if (result.card) { if (result.card) {
cardTracker.status = 'confirmed'; cardTracker.status = 'confirmed';
emitScannedCard(cardTracker, imageData, result.card, ocrMeta); await emitScannedCard(cardTracker, imageData, result.card, ocrMeta);
return; return;
} }

View file

@ -98,6 +98,7 @@
| `condition` | `VARCHAR(50)` default `'NM'` | NM / LP / MP / HP / DMG | | `condition` | `VARCHAR(50)` default `'NM'` | NM / LP / MP / HP / DMG |
| `is_foil` | `BOOLEAN` default `false` | | | `is_foil` | `BOOLEAN` default `false` | |
| `notes` | `TEXT` | | | `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)** | | | | **UNIQUE(user_id, card_id, is_foil)** |
### user_favorites ### user_favorites

View file

@ -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> | 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> | void}
*/
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE user_cards
DROP COLUMN IF EXISTS scan_image_url
`);
};

View file

@ -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' });
}
}

View file

@ -9,12 +9,23 @@ export default async function handler(req, res) {
} }
if (req.method === 'POST') { 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) { if (!cardId) {
return res.status(400).json({ error: 'Card ID is required' }); 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 // Check if user already owns this card
const existingResult = await sql` const existingResult = await sql`
SELECT * FROM user_cards SELECT * FROM user_cards
@ -22,18 +33,20 @@ export default async function handler(req, res) {
`; `;
if (existingResult.rows.length > 0) { 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; const newQuantity = existingResult.rows[0].quantity + quantity;
await sql` await sql`
UPDATE user_cards 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} WHERE user_id = ${user.userId} AND card_id = ${cardId} AND is_foil = ${is_foil}
`; `;
} else { } else {
// Insert new record // Insert new record
await sql` await sql`
INSERT INTO user_cards (user_id, card_id, 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}) VALUES (${user.userId}, ${cardId}, ${quantity}, ${condition}, ${is_foil}, ${scanImageUrl})
`; `;
} }

View file

@ -158,6 +158,7 @@ export default function Scanner() {
cardEntry = { cardEntry = {
...existing, ...existing,
quantity: (existing.quantity || 1) + 1, quantity: (existing.quantity || 1) + 1,
scanImageUrl: cardData.scanImageUrl || existing.scanImageUrl,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
setScannedCards((prev) => { setScannedCards((prev) => {
@ -303,12 +304,20 @@ export default function Scanner() {
); );
}; };
const buildCardPayload = (cardData) => ({ const buildCardPayload = (cardData) => {
cardId: cardData.databaseId, const payload = {
quantity: cardData.quantity || 1, cardId: cardData.databaseId,
condition: cardData.condition || 'NM', quantity: cardData.quantity || 1,
is_foil: Boolean(cardData.isFoil), 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 // Helper functions for API calls
const addToOwnedCards = async (cardData) => { const addToOwnedCards = async (cardData) => {