feat(scanner): persist scan captures to Blob (Brief 3) #44
6 changed files with 167 additions and 15 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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') {
|
||||
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})
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue