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:
parent
8f3fbe70a2
commit
cf5c0558f1
4 changed files with 297 additions and 1 deletions
177
lib/card-import/reconcile-submissions.js
Normal file
177
lib/card-import/reconcile-submissions.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { discoverMissingMtgSets, discoverMissingPokemonSets } from './discover.js';
|
import { discoverMissingMtgSets, discoverMissingPokemonSets } from './discover.js';
|
||||||
import { importMtgSet } from './mtg.js';
|
import { importMtgSet } from './mtg.js';
|
||||||
import { importPokemonSet } from './pokemon.js';
|
import { importPokemonSet } from './pokemon.js';
|
||||||
|
import { reconcileSubmissionsForImportedSet } from './reconcile-submissions.js';
|
||||||
|
|
||||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
|
@ -60,6 +61,8 @@ export async function runCatalogSync(options = {}) {
|
||||||
pendingMtgSets: missingMtg.length,
|
pendingMtgSets: missingMtg.length,
|
||||||
pendingPokemonSets: missingPokemon.length,
|
pendingPokemonSets: missingPokemon.length,
|
||||||
lorcana: 'skipped — manual Lorcana set map update required',
|
lorcana: 'skipped — manual Lorcana set map update required',
|
||||||
|
submissionsReconciled: 0,
|
||||||
|
reconciliation: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let index = 0; index < queue.length; index += 1) {
|
for (let index = 0; index < queue.length; index += 1) {
|
||||||
|
|
@ -78,6 +81,16 @@ export async function runCatalogSync(options = {}) {
|
||||||
});
|
});
|
||||||
summary.imported += result.imported;
|
summary.imported += result.imported;
|
||||||
summary.skipped += result.skipped;
|
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) {
|
} catch (error) {
|
||||||
console.error(`[runCatalogSync] Failed ${item.game}/${item.setCode}:`, error);
|
console.error(`[runCatalogSync] Failed ${item.game}/${item.setCode}:`, error);
|
||||||
summary.errors.push({
|
summary.errors.push({
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ const CardImport = () => {
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm max-w-2xl" style={{ color: 'var(--text-secondary)' }}>
|
<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.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -211,6 +211,12 @@ const CardImport = () => {
|
||||||
<p>
|
<p>
|
||||||
Imported {syncResult.imported} cards ({syncResult.skipped} skipped).
|
Imported {syncResult.imported} cards ({syncResult.skipped} skipped).
|
||||||
</p>
|
</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) && (
|
{(syncResult.pendingMtgSets != null || syncResult.pendingPokemonSets != null) && (
|
||||||
<p>
|
<p>
|
||||||
Still pending: {syncResult.pendingMtgSets} MTG sets,{' '}
|
Still pending: {syncResult.pendingMtgSets} MTG sets,{' '}
|
||||||
|
|
@ -227,6 +233,18 @@ const CardImport = () => {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 && (
|
{Array.isArray(syncResult.errors) && syncResult.errors.length > 0 && (
|
||||||
<ul className="list-disc pl-5 space-y-1 text-red-800">
|
<ul className="list-disc pl-5 space-y-1 text-red-800">
|
||||||
{syncResult.errors.map((entry) => (
|
{syncResult.errors.map((entry) => (
|
||||||
|
|
|
||||||
88
test/lib/card-import-reconcile-submissions.test.js
Normal file
88
test/lib/card-import-reconcile-submissions.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue