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 <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-05-27 13:55:31 -05:00
parent 24c9da4095
commit 0f1bf6a6b8
6 changed files with 167 additions and 15 deletions

View file

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

View file

@ -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

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

View file

@ -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) => {