deckhearth/lib/card-import/reconcile-submissions.js
Randall Stillwell 1cc2e28423 Migrate Deck Hearth off Vercel/Neon to homelab Dokploy stack.
Replace @vercel/postgres, Blob, and Upstash with lib/sql.js, MinIO object
storage, and CT 102 Redis rate limits. Add Dockerfile for Dokploy deploy,
homelab runbooks, Neon data-copy helper, and point CI smoke/visual at the
homelab URL instead of Vercel previews.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 09:32:13 -05:00

160 lines
4.4 KiB
JavaScript

import { sql } from '../sql.js';
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;
}