Auto-link pending scan submissions after catalog sync imports. (#51)

When a set lands via runCatalogSync, match pending card_submissions by set/name/number to catalog rows and approve them with promoted_card_id instead of leaving them in the admin queue.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
varutasu 2026-05-28 09:47:59 -05:00 committed by GitHub
parent 8f3fbe70a2
commit cf5c0558f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 297 additions and 1 deletions

View file

@ -0,0 +1,177 @@
import { sql } from '@vercel/postgres';
/**
* Normalize collector numbers so "015/208" and "15/208" compare equal.
*/
export function normalizeCardNumber(value) {
if (value == null || value === '') return '';
const trimmed = String(value).trim();
const slashIndex = trimmed.indexOf('/');
if (slashIndex === -1) {
return trimmed.toLowerCase();
}
const numerator = trimmed.slice(0, slashIndex).trim();
const denominator = trimmed.slice(slashIndex + 1).trim();
const normalizedNumerator = String(parseInt(numerator, 10));
if (normalizedNumerator === 'NaN') {
return trimmed.toLowerCase();
}
return `${normalizedNumerator}/${denominator}`.toLowerCase();
}
function normalizeGame(value) {
if (!value || typeof value !== 'string') return null;
const lower = value.trim().toLowerCase();
if (lower === 'mtg' || lower === 'magic') return 'MTG';
if (lower === 'pokemon' || lower === 'pokémon') return 'Pokemon';
if (lower === 'lorcana') return 'Lorcana';
return value.trim();
}
function importedSetGame(game) {
return game === 'pokemon' ? 'Pokemon' : 'MTG';
}
/**
* Whether a pending submission's OCR payload refers to the set that was just imported.
*/
export function submissionPayloadMatchesSet(payload, { game, setCode, setName }) {
if (!payload || typeof payload !== 'object') return false;
const payloadGame = normalizeGame(payload.game);
const expectedGame = importedSetGame(game);
if (payloadGame && payloadGame !== expectedGame) {
return false;
}
const code = (setCode || '').trim().toLowerCase();
const name = (setName || '').trim().toLowerCase();
const payloadSetCode = (payload.setCode || '').trim().toLowerCase();
const payloadSet = (payload.set || '').trim().toLowerCase();
if (!code && !name) return false;
return Boolean(
(payloadSetCode && payloadSetCode === code) ||
(payloadSet && (payloadSet === code || payloadSet === name))
);
}
function cardNumberMatches(catalogNumber, payloadNumber) {
if (!payloadNumber) return true;
if (!catalogNumber) return false;
return normalizeCardNumber(catalogNumber) === normalizeCardNumber(payloadNumber);
}
/**
* Pick the catalog row that satisfies a submission payload within an imported set.
* Returns the card id or null when ambiguous / no match.
*/
export function pickCatalogCardForPayload(catalogRows, payload) {
if (!payload?.name || catalogRows.length === 0) return null;
const cardNumber = payload.cardNumber?.trim() || null;
const matches = catalogRows.filter((row) => cardNumberMatches(row.card_number, cardNumber));
if (cardNumber) {
return matches.length === 1 ? matches[0].id : null;
}
return matches.length === 1 ? matches[0].id : null;
}
/**
* Find pending submissions for a set and link them to catalog rows when unambiguous.
*/
export async function reconcileSubmissionsForImportedSet({ game, setCode, setName }) {
const summary = {
game,
setCode,
setName,
matched: 0,
skipped: 0,
details: [],
};
const pendingResult = await sql`
SELECT id, ocr_payload
FROM card_submissions
WHERE status = 'pending'
ORDER BY created_at ASC
`;
const relevant = pendingResult.rows.filter((row) => {
const payload =
typeof row.ocr_payload === 'string'
? JSON.parse(row.ocr_payload)
: row.ocr_payload || {};
return submissionPayloadMatchesSet(payload, { game, setCode, setName });
});
if (relevant.length === 0) {
return summary;
}
const catalogResult = await sql`
SELECT id, name, set_name, set_code, card_number, game
FROM cards
WHERE LOWER(set_code) = LOWER(${setCode})
OR LOWER(set_name) = LOWER(${setName || setCode})
`;
const catalogRows = catalogResult.rows.filter(
(row) => row.game === importedSetGame(game)
);
for (const submission of relevant) {
const payload =
typeof submission.ocr_payload === 'string'
? JSON.parse(submission.ocr_payload)
: submission.ocr_payload || {};
const trimmedName = payload.name?.trim().toLowerCase();
const nameMatches = catalogRows.filter(
(row) => row.name?.trim().toLowerCase() === trimmedName
);
const cardId = pickCatalogCardForPayload(nameMatches, payload);
if (!cardId) {
summary.skipped += 1;
continue;
}
const updateResult = await sql`
UPDATE card_submissions
SET
status = 'approved',
promoted_card_id = ${cardId},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${submission.id}
AND status = 'pending'
RETURNING id
`;
if (updateResult.rows.length === 0) {
summary.skipped += 1;
continue;
}
summary.matched += 1;
summary.details.push({
submissionId: submission.id,
cardId,
name: payload.name,
cardNumber: payload.cardNumber || null,
});
}
if (summary.matched > 0) {
console.log(
`[reconcileSubmissionsForImportedSet] ${game}/${setCode}: matched ${summary.matched} submission(s)`
);
}
return summary;
}

View file

@ -1,6 +1,7 @@
import { discoverMissingMtgSets, discoverMissingPokemonSets } from './discover.js';
import { importMtgSet } from './mtg.js';
import { importPokemonSet } from './pokemon.js';
import { reconcileSubmissionsForImportedSet } from './reconcile-submissions.js';
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@ -60,6 +61,8 @@ export async function runCatalogSync(options = {}) {
pendingMtgSets: missingMtg.length,
pendingPokemonSets: missingPokemon.length,
lorcana: 'skipped — manual Lorcana set map update required',
submissionsReconciled: 0,
reconciliation: [],
};
for (let index = 0; index < queue.length; index += 1) {
@ -78,6 +81,16 @@ export async function runCatalogSync(options = {}) {
});
summary.imported += result.imported;
summary.skipped += result.skipped;
const reconcileResult = await reconcileSubmissionsForImportedSet({
game: item.game,
setCode: item.setCode,
setName: item.name,
});
summary.submissionsReconciled += reconcileResult.matched;
if (reconcileResult.matched > 0 || reconcileResult.skipped > 0) {
summary.reconciliation.push(reconcileResult);
}
} catch (error) {
console.error(`[runCatalogSync] Failed ${item.game}/${item.setCode}:`, error);
summary.errors.push({

View file

@ -174,7 +174,7 @@ const CardImport = () => {
</h2>
<p className="text-sm max-w-2xl" style={{ color: 'var(--text-secondary)' }}>
Import up to three newest missing MTG and Pokémon sets — the same job the weekly Vercel cron runs.
Use this to backfill recent releases without curl.
Pending scan submissions for those sets are auto-linked to the catalog when a unique match exists.
</p>
</div>
<button
@ -211,6 +211,12 @@ const CardImport = () => {
<p>
Imported {syncResult.imported} cards ({syncResult.skipped} skipped).
</p>
{syncResult.submissionsReconciled > 0 && (
<p>
Linked {syncResult.submissionsReconciled} pending scan submission
{syncResult.submissionsReconciled === 1 ? '' : 's'} to catalog cards.
</p>
)}
{(syncResult.pendingMtgSets != null || syncResult.pendingPokemonSets != null) && (
<p>
Still pending: {syncResult.pendingMtgSets} MTG sets,{' '}
@ -227,6 +233,18 @@ const CardImport = () => {
))}
</ul>
)}
{Array.isArray(syncResult.reconciliation) && syncResult.reconciliation.length > 0 && (
<ul className="list-disc pl-5 space-y-1">
{syncResult.reconciliation.flatMap((entry) =>
entry.details.map((detail) => (
<li key={`submission-${detail.submissionId}`}>
Submission #{detail.submissionId}: {detail.name}
{detail.cardNumber ? ` (${detail.cardNumber})` : ''} → card #{detail.cardId}
</li>
))
)}
</ul>
)}
{Array.isArray(syncResult.errors) && syncResult.errors.length > 0 && (
<ul className="list-disc pl-5 space-y-1 text-red-800">
{syncResult.errors.map((entry) => (

View file

@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import {
normalizeCardNumber,
pickCatalogCardForPayload,
submissionPayloadMatchesSet,
} from '../../lib/card-import/reconcile-submissions.js';
describe('normalizeCardNumber', () => {
it('treats leading-zero numerators as equivalent', () => {
expect(normalizeCardNumber('015/208')).toBe('15/208');
expect(normalizeCardNumber('15/208')).toBe('15/208');
});
});
describe('submissionPayloadMatchesSet', () => {
it('matches by set code or set name for the imported game', () => {
const imported = { game: 'pokemon', setCode: 'sv10', setName: 'Perfect Order' };
expect(
submissionPayloadMatchesSet(
{ name: 'Seel', setCode: 'sv10', game: 'Pokemon' },
imported
)
).toBe(true);
expect(
submissionPayloadMatchesSet(
{ name: 'Seel', set: 'Perfect Order', game: 'pokemon' },
imported
)
).toBe(true);
expect(
submissionPayloadMatchesSet(
{ name: 'Seel', setCode: 'sv9', game: 'Pokemon' },
imported
)
).toBe(false);
expect(
submissionPayloadMatchesSet({ name: 'Bolt', setCode: 'fin', game: 'MTG' }, {
game: 'pokemon',
setCode: 'sv10',
setName: 'Perfect Order',
})
).toBe(false);
});
});
describe('pickCatalogCardForPayload', () => {
const catalogRows = [
{ id: 1, card_number: '015/208' },
{ id: 2, card_number: '016/208' },
];
it('matches a specific printing when the collector number aligns', () => {
expect(
pickCatalogCardForPayload(catalogRows, {
name: 'Seel',
cardNumber: '15/208',
})
).toBe(1);
});
it('returns null when the collector number is ambiguous or missing among siblings', () => {
expect(
pickCatalogCardForPayload(catalogRows, {
name: 'Seel',
cardNumber: '99/208',
})
).toBeNull();
expect(
pickCatalogCardForPayload(catalogRows, {
name: 'Seel',
})
).toBeNull();
});
it('matches name-only payloads when exactly one catalog row exists', () => {
expect(
pickCatalogCardForPayload([{ id: 42, card_number: '1/100' }], {
name: 'Pikachu',
})
).toBe(42);
});
});