Share card-number normalization across reconcile and identify paths, retry set/name matches when OCR uses leading-zero collector numbers, and extend in-flight locks to all scanner destination actions with disabled Mark Owned feedback. Co-authored-by: Cursor <cursoragent@cursor.com>
160 lines
4.4 KiB
JavaScript
160 lines
4.4 KiB
JavaScript
import { sql } from '@vercel/postgres';
|
|
|
|
import { cardNumbersEquivalent, normalizeCardNumber } from '../card-number-utils.js';
|
|
|
|
export { normalizeCardNumber };
|
|
|
|
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 cardNumbersEquivalent(catalogNumber, 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;
|
|
}
|