Reconcile pending scan submissions after catalog sync #51

Merged
varutasu merged 1 commit from feat/reconcile-submissions-after-catalog-sync into main 2026-05-28 10:47:59 -04:00
4 changed files with 297 additions and 1 deletions
Showing only changes of commit ef08398149 - Show all commits

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);
});
});