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:
parent
24c9da4095
commit
0f1bf6a6b8
6 changed files with 167 additions and 15 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
28
migrations/1779908094455_add-user-cards-scan-image-url.js
Normal file
28
migrations/1779908094455_add-user-cards-scan-image-url.js
Normal 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
|
||||||
|
`);
|
||||||
|
};
|
||||||
67
pages/api/scan/upload-image.js
Normal file
67
pages/api/scan/upload-image.js
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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})
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue