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 { 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({
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
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