96 lines
3.1 KiB
JavaScript
96 lines
3.1 KiB
JavaScript
|
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||
|
|
import { checkScanRateLimit } from '../../../lib/rate-limit.js';
|
||
|
|
import { analyzeCardImage } from '../../../lib/scan-vision.js';
|
||
|
|
import { submitScanForReview, logScanAttempt } from '../../../lib/card-catalog-match.js';
|
||
|
|
|
||
|
|
export default async function handler(req, res) {
|
||
|
|
if (req.method !== 'POST') {
|
||
|
|
return res.status(405).json({ error: 'Method not allowed' });
|
||
|
|
}
|
||
|
|
|
||
|
|
const startedAt = Date.now();
|
||
|
|
|
||
|
|
try {
|
||
|
|
const user = await getUserFromRequest(req);
|
||
|
|
if (!user) {
|
||
|
|
return res.status(401).json({ error: 'Authentication required' });
|
||
|
|
}
|
||
|
|
|
||
|
|
const { allowed, reset } = await checkScanRateLimit(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,
|
||
|
|
name,
|
||
|
|
game,
|
||
|
|
candidateCardIds = [],
|
||
|
|
} = req.body || {};
|
||
|
|
|
||
|
|
let fields = {
|
||
|
|
name: typeof name === 'string' ? name.trim() : null,
|
||
|
|
game: game || null,
|
||
|
|
ocrData: {
|
||
|
|
confidence: 65,
|
||
|
|
rawText: name || null,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
if (imageData && typeof imageData === 'string' && imageData.length <= 6_000_000) {
|
||
|
|
try {
|
||
|
|
const ocrResult = await analyzeCardImage(imageData);
|
||
|
|
if (ocrResult.isCard || ocrResult.cardName) {
|
||
|
|
fields = {
|
||
|
|
name: ocrResult.cardName || fields.name,
|
||
|
|
set: ocrResult.setName || null,
|
||
|
|
setCode: ocrResult.setCode || null,
|
||
|
|
cardNumber: ocrResult.cardNumber || null,
|
||
|
|
game: game || ocrResult.game || null,
|
||
|
|
cardType: ocrResult.cardType || null,
|
||
|
|
rarity: ocrResult.rarity || null,
|
||
|
|
hp: ocrResult.hp || null,
|
||
|
|
manaCost: ocrResult.manaCost || null,
|
||
|
|
ocrData: {
|
||
|
|
confidence: ocrResult.confidence || 65,
|
||
|
|
rawText: ocrResult.rawText || name || null,
|
||
|
|
abilities: ocrResult.abilities || [],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
} catch (visionError) {
|
||
|
|
console.warn('[submit-for-review] Vision failed, using name hint:', visionError.message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!fields.name) {
|
||
|
|
return res.status(400).json({ error: 'Card name is required to submit for review' });
|
||
|
|
}
|
||
|
|
|
||
|
|
const candidateIds = Array.isArray(candidateCardIds)
|
||
|
|
? candidateCardIds.filter((id) => Number.isInteger(id) || (typeof id === 'string' && id !== ''))
|
||
|
|
: [];
|
||
|
|
|
||
|
|
const matchResult = await submitScanForReview(user.userId, fields, candidateIds);
|
||
|
|
|
||
|
|
await logScanAttempt({
|
||
|
|
userId: user.userId,
|
||
|
|
ocrText: fields.ocrData?.rawText,
|
||
|
|
ocrConfidence: fields.ocrData?.confidence,
|
||
|
|
layer: 2,
|
||
|
|
resultKind: 'submitted',
|
||
|
|
latencyMs: Date.now() - startedAt,
|
||
|
|
});
|
||
|
|
|
||
|
|
return res.status(200).json({
|
||
|
|
needsReview: true,
|
||
|
|
submissionId: matchResult.submissionId,
|
||
|
|
message: matchResult.message,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[POST /api/scan/submit-for-review]', error);
|
||
|
|
return res.status(500).json({ error: 'Internal server error' });
|
||
|
|
}
|
||
|
|
}
|