fix(scanner): catalog gap review path + not-listed disambiguation #40

Merged
varutasu merged 1 commit from fix/scanner-catalog-gap-review into main 2026-05-27 14:42:52 -04:00
7 changed files with 646 additions and 48 deletions

View file

@ -0,0 +1,177 @@
---
name: catalog-sync-vercel-cron
classification: infra-only
success_metric: |
New MTG and Pokémon sets appear in the cards catalog within one week of
upstream API availability without manual admin import; each cron run logs
imported/skipped set counts and fails loud on errors.
skip:
- ia
- ux
- visual
- a11y
- design
status: open
created: 2026-05-27
depends_on:
- redesign-scanner-flow
- scanner-correctness-polish
- add-real-ocr-layer
blocked_by_policy: |
Operator requested finishing the scanner pipeline and other in-flight convoys
before starting this work. Do not pick up until those are merged or explicitly
reprioritized.
---
# Convoy: catalog-sync-vercel-cron
Scheduled catalog freshness via **Vercel Cron** (not GitHub Actions — operator
preference: already on Vercel paid plan; avoids GitHub Actions minute limits).
## Why
The Perfect Order Seel scan failure (2026-05-27) exposed a catalog gap: Layer 1
matched the name "Seel" against nine *old* printings because **Perfect Order is
not in the database**. `card_submissions` now bridges unknown cards for admin
review, but scanning still degrades until new sets are imported.
Today catalog updates are **fully manual**:
- Admin UI at `/admin/card-import` (MTG + Pokémon only; set code typed by hand)
- One-off scripts (`import-popular-sets.js`, `bulk-import-all.js`) with **static**
set lists
- Lorcana import uses a **hardcoded** `setCodeMap` in `import-lorcana.js`
There is no scheduled job. `scripts/README.md` documents "import new sets as they
release" as the ongoing process — easy to forget.
## Operator decision (2026-05-27)
- **Scheduler:** Vercel Cron hitting a protected API route on the production
deployment (or a dedicated Preview with prod DB — architect decides at gate-1).
- **Not GitHub Actions cron** — operator prefers Vercel to stay within GitHub
Actions free-tier limits.
- **Timing:** Build **after** scanner pipeline convoys land (see `blocked_by_policy`
above). Scanner correctness + UX (#4#6 in the scanner audit portfolio) take
priority.
## Scope
### In scope
1. **Extract shared import logic** from `pages/api/cards/import-mtg.js` and
`pages/api/cards/import-pokemon.js` into `lib/card-import/` (or similar) so
cron, admin UI, and scripts call one code path. Idempotent skips preserved
(MTG: `scryfall_id`; Pokémon: existing duplicate checks).
2. **Set discovery (delta sync)**
- **MTG:** Scryfall `GET /sets` — compare `code` + release date against
`SELECT DISTINCT set_code FROM cards WHERE game = 'MTG'`.
- **Pokémon:** Pokémon TCG API `GET /v2/sets` — compare `id` against catalog;
filter to sets released in the last N days or not yet present in DB.
- **Lorcana:** Out of scope for v1 automation unless Lorcast set list is
fetched dynamically; v1 may log "manual Lorcana map update required" and skip.
3. **Protected cron endpoint** — e.g. `GET /api/cron/sync-catalog` or
`POST /api/admin/sync-catalog`:
- Authenticate via `CRON_SECRET` header (Vercel Cron
[securing cron jobs](https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs)
pattern) — **not** JWT admin session.
- Reuse or bypass `checkImportRateLimit` thoughtfully: cron is a single
system actor; may need a dedicated limiter class or internal-only bypass with
hard cap on sets per run (e.g. max 3 sets/run, 1s delay between sets).
- Never expose unauthenticated bulk INSERT into `cards`.
4. **`vercel.json` cron schedule** — weekly default (e.g. `0 6 * * 1` UTC);
`workflow_dispatch`-equivalent: manual hit with `CRON_SECRET` for on-demand runs.
5. **Observability**
- Minimum: structured console log + HTTP 200 body with `{ imported, skipped,
errors, setsProcessed }`.
- Nice-to-have (v1.1): `catalog_sync_runs` migration (`started_at`, `finished_at`,
`sets_imported`, `error_json`).
6. **Docs** — update `scripts/README.md` § "For Ongoing Management" to point at
cron + manual override via admin UI.
### Out of scope (v1)
- Auto-promoting `card_submissions` when a matching set import completes (follow-up
convoy `reconcile-submissions-after-catalog-sync`).
- Hourly sync (weekly is sufficient for TCG release cadence).
- Full `bulk-import-all.js` replacement or re-import of historical sets.
- GitHub Actions scheduled workflow (explicitly rejected by operator).
- Running import jobs ad-hoc against prod without pacing (AGENTS.md no-go: rate limits).
## Proposed architecture
```
Vercel Cron (weekly)
→ GET /api/cron/sync-catalog (+ Authorization: Bearer $CRON_SECRET)
→ discoverNewSets('mtg' | 'pokemon')
→ for each missing set (max N per run):
→ importSetFromScryfall(code) / importSetFromPokemonTcg(id)
→ delay 13s (respect upstream + existing import rate limits)
→ return summary JSON
```
**Env vars (new):**
| Var | Purpose |
| --- | --- |
| `CRON_SECRET` | Vercel Cron auth header; rotate via Vercel dashboard |
| `POKEMON_TCG_API_KEY` | If not already set — Pokémon API key for set discovery |
**Existing vars reused:** `POSTGRES_URL`, Scryfall needs no key.
## Roles invoked
1. `role-architect` — gate-1: cron auth shape, rate-limit policy, Lorcana v1 stance,
sets-per-run cap.
2. `role-implementer` — brief 1 (lib extract + cron route + vercel.json); brief 2
(discovery + docs) if split.
3. `role-reviewer` — post-PR.
## Todos
- [ ] Architect: ratify cron auth, import rate-limit bypass/cap, schedule cadence
- [ ] Extract `lib/card-import/mtg.js` + `lib/card-import/pokemon.js`
- [ ] Implement set discovery + delta diff
- [ ] Add `/api/cron/sync-catalog` + `vercel.json` cron entry
- [ ] Document operator setup (`CRON_SECRET`, manual trigger, monitoring)
- [ ] Smoke: one dry-run against staging Neon branch
## Operator action required (at ship time)
1. Set `CRON_SECRET` in Vercel project env (generate: `openssl rand -base64 32`).
2. Confirm Pokémon TCG API key is present if set discovery uses authenticated endpoints.
3. After first cron run, spot-check Vercel function logs + `cards` row count for a
known recent set.
4. Optional: alert on cron failure (Vercel log drain / email) — not required for v1.
## Relationship to scanner work
| Scanner deliverable | How catalog sync helps |
| --- | --- |
| `card_submissions` queue (shipped) | Safety net when sync hasn't run yet |
| Disambiguation + "not listed" (in progress) | UX when catalog is stale |
| **This convoy** | Reduces stale-catalog frequency at the source |
Queue **after** `redesign-scanner-flow`, `scanner-correctness-polish`, and
`rename-collections-vocabulary` unless operator reprioritizes.
## Follow-up convoys (not v1)
- **`reconcile-submissions-after-catalog-sync`** — when a set import lands, auto-match
pending `card_submissions` with matching `ocr_payload` set/name/number.
- **`lorcana-dynamic-set-discovery`** — replace hardcoded `setCodeMap` in
`import-lorcana.js`.
- **`catalog-sync-runs-table`** — migration for audit trail if console logs prove
insufficient.
## Test plan
- Unit: set-diff logic (mock DB rows vs mock API set list).
- Integration (staging): cron endpoint with `CRON_SECRET` imports one known small set;
second run skips all (idempotent).
- Manual: verify admin `/admin/card-import` still works after lib extraction.

View file

@ -531,6 +531,23 @@ Six convoys authored from the scanner audit portfolio plan. Dependency order:
Do not fold into the six scanner convoys above; queue as its own architect-led Do not fold into the six scanner convoys above; queue as its own architect-led
migration convoy after the scan pipeline stabilizes. migration convoy after the scan pipeline stabilizes.
### Catalog freshness (deferred — post-scanner)
- **`catalog-sync-vercel-cron`** (priority: P2 infra / data hygiene;
**deferred until scanner pipeline convoys finish**). Operator decision
2026-05-27: use **Vercel Cron** (not GitHub Actions) for weekly delta sync of
new MTG + Pokémon sets. Motivation: Perfect Order Seel scan showed the catalog
has no row for unreleased/unimported sets; `card_submissions` is the safety net
but does not replace keeping `cards` current. v1 scope: extract shared import
logic from `import-mtg` / `import-pokemon`, discover missing sets via Scryfall +
Pokémon TCG API `/sets`, protected `/api/cron/sync-catalog` with `CRON_SECRET`,
`vercel.json` weekly schedule, paced imports respecting upstream rate limits.
Lorcana auto-discovery deferred (hardcoded `setCodeMap` today). **Do not start
until** `redesign-scanner-flow`, `scanner-correctness-polish`, and in-flight
scanner fixes (catalog-gap disambiguation, foil vision) are merged — operator
explicitly requested finishing scanner work first. Convoy:
`.convoys/catalog-sync-vercel-cron.md`.
## Self-analytics ## Self-analytics
After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules. After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules.

View file

@ -4,6 +4,8 @@ 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);
const [disambiguation, setDisambiguation] = useState(null); const [disambiguation, setDisambiguation] = useState(null);
const [scanNotice, setScanNotice] = useState(null);
const [submittingReview, setSubmittingReview] = useState(false);
const videoRef = useRef(null); const videoRef = useRef(null);
const canvasRef = useRef(null); const canvasRef = useRef(null);
@ -19,6 +21,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
const visionCooldownUntilRef = useRef(0); const visionCooldownUntilRef = useRef(0);
const activeVerificationRef = useRef(0); const activeVerificationRef = useRef(0);
const lastErrorAtRef = useRef(0); const lastErrorAtRef = useRef(0);
const disambiguationRefineRef = useRef(null);
// Mana symbol settings // Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
@ -276,6 +279,72 @@ export default function CameraScanner({ onCardScanned, onError }) {
const { cardTracker, imageData, ocrMeta } = disambiguation; const { cardTracker, imageData, ocrMeta } = disambiguation;
emitScannedCard(cardTracker, imageData, candidate, ocrMeta); emitScannedCard(cardTracker, imageData, candidate, ocrMeta);
setDisambiguation(null); setDisambiguation(null);
disambiguationRefineRef.current = null;
};
const showScanNotice = (message) => {
setScanNotice(message);
setTimeout(() => setScanNotice(null), 8000);
};
const handleReviewSubmitted = (cardTracker, message) => {
if (cardTracker) cardTracker.status = 'confirmed';
setDisambiguation(null);
disambiguationRefineRef.current = null;
showScanNotice(message);
};
const handleNotInCatalog = async () => {
if (!disambiguation || submittingReview) return;
setSubmittingReview(true);
const { cardTracker, imageData, candidates, ocrMeta } = disambiguation;
const guessedName =
ocrMeta?.cardName ||
ocrMeta?.query ||
candidates?.[0]?.name ||
null;
try {
const response = await fetch('/api/scan/submit-for-review', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({
imageData,
name: guessedName,
candidateCardIds: (candidates || []).map((c) => c.id),
}),
});
if (response.status === 429) {
visionCooldownUntilRef.current = Date.now() + 60_000;
reportScannerError('Too many scan attempts. Please wait a moment and try again.');
return;
}
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
throw new Error(errBody.error || 'Failed to submit scan for review');
}
const result = await response.json();
handleReviewSubmitted(cardTracker, result.message);
} catch (error) {
reportScannerError(error.message || 'Failed to submit scan for review');
} finally {
setSubmittingReview(false);
}
};
const candidateMatchesSetHint = (candidate, setName, setCode) => {
if (!setName && !setCode) return true;
const hint = (setName || setCode || '').toLowerCase();
const setNameLower = (candidate.set_name || '').toLowerCase();
const setCodeLower = (candidate.set_code || '').toLowerCase();
return setNameLower.includes(hint) || hint.includes(setNameLower) || setCodeLower === hint;
}; };
const processIdentifyResponse = async (cardTracker, imageData, result) => { const processIdentifyResponse = async (cardTracker, imageData, result) => {
@ -311,9 +380,15 @@ export default function CameraScanner({ onCardScanned, onError }) {
return; return;
} }
if (result.needsReview || result.needsUserInput) { if (result.needsReview) {
cardTracker.status = 'confirmed';
showScanNotice(result.message || 'Scan saved for admin review.');
return;
}
if (result.needsUserInput) {
cardTracker.status = 'negative'; cardTracker.status = 'negative';
reportScannerError(result.message || 'Could not identify card — saved for review or retry.'); reportScannerError(result.message || 'Could not identify card — try again or submit for review.');
return; return;
} }
@ -322,12 +397,133 @@ export default function CameraScanner({ onCardScanned, onError }) {
}; };
const reportScannerError = (message) => { const reportScannerError = (message) => {
if (disambiguation) return;
const now = Date.now(); const now = Date.now();
if (now - lastErrorAtRef.current < 4000) return; if (now - lastErrorAtRef.current < 4000) return;
lastErrorAtRef.current = now; lastErrorAtRef.current = now;
onError?.(message); onError?.(message);
}; };
useEffect(() => {
if (!disambiguation?.imageData) return;
if (Date.now() < visionCooldownUntilRef.current) return;
const refineKey = disambiguation.cardTracker?.id ?? 'modal';
if (disambiguationRefineRef.current === refineKey) return;
disambiguationRefineRef.current = refineKey;
let cancelled = false;
(async () => {
try {
const response = await fetch('/api/scan/identify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({ imageData: disambiguation.imageData }),
});
if (cancelled) return;
if (response.status === 429) {
visionCooldownUntilRef.current = Date.now() + 60_000;
return;
}
if (!response.ok) return;
const result = await response.json();
if (cancelled) return;
if (result.needsReview) {
handleReviewSubmitted(disambiguation.cardTracker, result.message);
return;
}
if (result.card) {
emitScannedCard(
disambiguation.cardTracker,
disambiguation.imageData,
result.card,
{
confidence: result.ocr?.confidence,
rawText: result.ocr?.rawText,
abilities: result.card?.ocr?.abilities || [],
}
);
setDisambiguation(null);
disambiguationRefineRef.current = null;
return;
}
const setHint = result.ocr?.setName || result.ocr?.setCode;
if (result.matches?.length && setHint) {
const filtered = disambiguation.candidates.filter((candidate) =>
candidateMatchesSetHint(candidate, result.ocr.setName, result.ocr.setCode)
);
if (filtered.length === 0 && setHint) {
try {
const submitRes = await fetch('/api/scan/submit-for-review', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({
imageData: disambiguation.imageData,
name: result.ocr?.cardName || disambiguation.candidates[0]?.name,
candidateCardIds: disambiguation.candidates.map((c) => c.id),
}),
});
if (submitRes.ok) {
const submitResult = await submitRes.json();
handleReviewSubmitted(disambiguation.cardTracker, submitResult.message);
}
} catch {
setDisambiguation((current) =>
current
? {
...current,
visionHint: setHint,
message: `No "${setHint}" printing in our catalog. Tap "My card isn't listed" to save for admin review.`,
}
: current
);
}
return;
}
if (filtered.length === 1) {
handleDisambiguationPick(filtered[0]);
return;
}
if (filtered.length > 1 && filtered.length < disambiguation.candidates.length) {
setDisambiguation((current) =>
current
? {
...current,
candidates: filtered,
message: `Narrowed to ${filtered.length} printings matching "${setHint}".`,
visionHint: setHint,
}
: current
);
}
}
} catch (error) {
console.warn('Disambiguation vision refine failed:', error);
}
})();
return () => {
cancelled = true;
};
}, [disambiguation?.cardTracker?.id, disambiguation?.imageData]);
// Server-side card identification // Server-side card identification
const verifyCardShape = async (cardTracker) => { const verifyCardShape = async (cardTracker) => {
if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return; if (!videoRef.current || !canvasRef.current || cardTracker.status !== 'detecting') return;
@ -568,6 +764,19 @@ export default function CameraScanner({ onCardScanned, onError }) {
return ( return (
<div className="w-full h-full flex flex-col"> <div className="w-full h-full flex flex-col">
{scanNotice && (
<div
className="mx-4 mt-3 px-4 py-3 rounded-lg text-sm border"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--accent-ember)',
color: 'var(--text-primary)',
}}
role="status"
>
{scanNotice}
</div>
)}
{/* Camera Feed Container */} {/* Camera Feed Container */}
<div <div
className="flex-1 relative rounded-2xl overflow-hidden mb-4" className="flex-1 relative rounded-2xl overflow-hidden mb-4"
@ -796,6 +1005,11 @@ export default function CameraScanner({ onCardScanned, onError }) {
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
{disambiguation.message || 'Multiple matches found. Select the correct printing.'} {disambiguation.message || 'Multiple matches found. Select the correct printing.'}
</p> </p>
{disambiguation.visionHint && (
<p className="text-xs mb-3 px-2 py-1 rounded" style={{ color: 'var(--accent-ember)', backgroundColor: 'var(--bg-tertiary)' }}>
Vision detected set: {disambiguation.visionHint}
</p>
)}
<div className="space-y-2"> <div className="space-y-2">
{disambiguation.candidates.map((candidate) => ( {disambiguation.candidates.map((candidate) => (
<button <button
@ -821,8 +1035,20 @@ export default function CameraScanner({ onCardScanned, onError }) {
</div> </div>
<button <button
type="button" type="button"
onClick={() => setDisambiguation(null)} onClick={handleNotInCatalog}
className="mt-4 w-full py-2 rounded-lg border text-sm" disabled={submittingReview}
className="mt-4 w-full py-2 rounded-lg text-sm font-medium disabled:opacity-50"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
>
{submittingReview ? 'Submitting…' : "My card isn't listed — send for review"}
</button>
<button
type="button"
onClick={() => {
setDisambiguation(null);
disambiguationRefineRef.current = null;
}}
className="mt-2 w-full py-2 rounded-lg border text-sm"
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }} style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
> >
Cancel Cancel

View file

@ -34,6 +34,22 @@ function buildOcrPayload(fields) {
}; };
} }
export async function submitScanForReview(userId, fields, candidateIds = []) {
const submissionId = await createCardSubmission(userId, fields, candidateIds);
const label = [fields.name, fields.set || fields.setCode, fields.cardNumber]
.filter(Boolean)
.join(' · ');
return {
type: 'submitted',
card: null,
submissionId,
needsReview: true,
message: label
? `"${label}" is not in our catalog yet. Your scan was saved for admin review (submission #${submissionId}).`
: `Your scan was saved for admin review (submission #${submissionId}).`,
};
}
async function createCardSubmission(userId, fields, candidateIds = []) { async function createCardSubmission(userId, fields, candidateIds = []) {
const ocrPayload = buildOcrPayload(fields); const ocrPayload = buildOcrPayload(fields);
const result = await sql` const result = await sql`
@ -95,6 +111,24 @@ export async function matchCardInCatalog({
`; `;
if (exactResult.rows.length > 0) { if (exactResult.rows.length > 0) {
existingCard = exactResult.rows[0]; existingCard = exactResult.rows[0];
} else {
return submitScanForReview(
userId,
{
name: trimmedName,
set,
setCode,
cardNumber,
game,
cardType,
rarity,
hp,
manaCost,
ocrData,
scanImageUrl,
},
[]
);
} }
} }
@ -188,6 +222,44 @@ export async function matchCardInCatalog({
} }
if (existingCard) { if (existingCard) {
const hasSpecificPrinting = Boolean((set || setCode) && cardNumber);
if (hasSpecificPrinting) {
const printingMatch = await sql`
SELECT * FROM cards
WHERE id = ${existingCard.id}
AND (
LOWER(card_number) = LOWER(${cardNumber})
AND (
LOWER(set_name) = LOWER(${set || setCode})
OR LOWER(set_code) = LOWER(${setCode || set})
)
)
LIMIT 1
`;
if (printingMatch.rows.length === 0) {
const siblingIds = await sql`
SELECT id FROM cards WHERE LOWER(name) = LOWER(${trimmedName})
`;
return submitScanForReview(
userId,
{
name: trimmedName,
set,
setCode,
cardNumber,
game,
cardType,
rarity,
hp,
manaCost,
ocrData,
scanImageUrl,
},
siblingIds.rows.map((row) => row.id)
);
}
} else {
const siblingsResult = await sql` const siblingsResult = await sql`
SELECT * FROM cards SELECT * FROM cards
WHERE LOWER(name) = LOWER(${trimmedName}) WHERE LOWER(name) = LOWER(${trimmedName})
@ -207,6 +279,7 @@ export async function matchCardInCatalog({
message: `Found ${siblingsResult.rows.length} printings of "${trimmedName}". Confirm the correct one.`, message: `Found ${siblingsResult.rows.length} printings of "${trimmedName}". Confirm the correct one.`,
}; };
} }
}
return { return {
type: 'matched', type: 'matched',
@ -227,19 +300,23 @@ export async function matchCardInCatalog({
}; };
} }
const submissionId = await createCardSubmission( return submitScanForReview(
userId, userId,
{ name: trimmedName, set, setCode, cardNumber, game, cardType, rarity, hp, manaCost, ocrData, scanImageUrl }, {
name: trimmedName,
set,
setCode,
cardNumber,
game,
cardType,
rarity,
hp,
manaCost,
ocrData,
scanImageUrl,
},
[] []
); );
return {
type: 'submitted',
card: null,
submissionId,
needsReview: true,
message: `Card "${trimmedName}" was not found in the catalog. Your scan was saved for admin review (submission #${submissionId}).`,
};
} }
export async function logScanAttempt({ export async function logScanAttempt({

View file

@ -4,7 +4,9 @@ const DEFAULT_VISION_MODEL =
const CARD_PROMPT = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.). const CARD_PROMPT = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore random objects, books, papers, phone screens, screenshots, blurry images, and non-card gaming items. HOLOGRAPHIC / FOIL CARDS: Many cards have reflective foil surfaces with glare or rainbow streaks. Do NOT reject these as "not a card" read through glare when possible and extract any visible name, set, and collector number.
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore random objects, books, papers, phone screens, screenshots, and non-card gaming items. Blurry images with no readable card frame should be rejected.
If you detect a trading card, extract information in this JSON format: If you detect a trading card, extract information in this JSON format:
{ {

View file

@ -69,6 +69,18 @@ function formatCardResponse(card, ocrResult) {
}; };
} }
function buildOcrPayload(ocrResult) {
return {
confidence: ocrResult.confidence,
rawText: ocrResult.rawText,
cardName: ocrResult.cardName,
setName: ocrResult.setName,
setCode: ocrResult.setCode,
cardNumber: ocrResult.cardNumber,
abilities: ocrResult.abilities,
};
}
export default async function handler(req, res) { export default async function handler(req, res) {
if (req.method !== 'POST') { if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' }); return res.status(405).json({ error: 'Method not allowed' });
@ -100,8 +112,12 @@ export default async function handler(req, res) {
const ocrResult = await analyzeCardImage(imageData); const ocrResult = await analyzeCardImage(imageData);
const latencyMs = Date.now() - startedAt; const latencyMs = Date.now() - startedAt;
const hasPartialCard =
ocrResult.cardName &&
typeof ocrResult.cardName === 'string' &&
ocrResult.cardName.trim().length > 0;
if (!ocrResult.isCard || ocrResult.confidence <= 60) { if ((!ocrResult.isCard || ocrResult.confidence <= 60) && !hasPartialCard) {
await logScanAttempt({ await logScanAttempt({
userId: user.userId, userId: user.userId,
ocrText: ocrResult.rawText, ocrText: ocrResult.rawText,
@ -168,11 +184,7 @@ export default async function handler(req, res) {
card: null, card: null,
matches: matchResult.matches, matches: matchResult.matches,
needsUserSelection: true, needsUserSelection: true,
ocr: { ocr: buildOcrPayload(ocrResult),
confidence: ocrResult.confidence,
rawText: ocrResult.rawText,
cardName: ocrResult.cardName,
},
message: matchResult.message, message: matchResult.message,
}); });
} }
@ -191,11 +203,7 @@ export default async function handler(req, res) {
card: null, card: null,
submissionId: matchResult.submissionId, submissionId: matchResult.submissionId,
needsReview: true, needsReview: true,
ocr: { ocr: buildOcrPayload(ocrResult),
confidence: ocrResult.confidence,
rawText: ocrResult.rawText,
cardName: ocrResult.cardName,
},
message: matchResult.message, message: matchResult.message,
}); });
} }
@ -213,11 +221,7 @@ export default async function handler(req, res) {
isCard: true, isCard: true,
card: null, card: null,
needsUserInput: true, needsUserInput: true,
ocr: { ocr: buildOcrPayload(ocrResult),
confidence: ocrResult.confidence,
rawText: ocrResult.rawText,
cardName: ocrResult.cardName,
},
message: matchResult.message, message: matchResult.message,
}); });
} catch (error) { } catch (error) {

View file

@ -0,0 +1,95 @@
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' });
}
}