2026-05-27 09:47:05 -04:00
import { sql } from '@vercel/postgres' ;
function mapCardRow ( card ) {
return {
id : card . id ,
name : card . name ,
set _name : card . set _name ,
set _code : card . set _code ,
card _number : card . card _number ,
game : card . game ,
rarity : card . rarity ,
image _url : card . image _url ,
card _type : card . card _type ,
mana _cost : card . mana _cost ,
hp : card . power ,
} ;
}
function buildOcrPayload ( fields ) {
return {
name : fields . name ? . trim ( ) || null ,
set : fields . set || null ,
setCode : fields . setCode || null ,
cardNumber : fields . cardNumber || null ,
game : fields . game || null ,
cardType : fields . cardType || null ,
rarity : fields . rarity || null ,
hp : fields . hp || null ,
manaCost : fields . manaCost || null ,
rawText : fields . ocrData ? . rawText || null ,
abilities : fields . ocrData ? . abilities || [ ] ,
flavorText : fields . ocrData ? . flavorText || null ,
artist : fields . ocrData ? . artist || null ,
} ;
}
2026-05-27 14:26:14 -04:00
export async function submitScanForReview ( userId , fields , candidateIds = [ ] ) {
const submissionId = await createCardSubmission ( userId , fields , candidateIds ) ;
const label = [ fields . name , fields . set || fields . setCode , fields . cardNumber ]
. filter ( Boolean )
. join ( ' · ' ) ;
return {
type : 'submitted' ,
card : null ,
submissionId ,
needsReview : true ,
message : label
? ` " ${ label } " is not in our catalog yet. Your scan was saved for admin review (submission # ${ submissionId } ). `
: ` Your scan was saved for admin review (submission # ${ submissionId } ). ` ,
} ;
}
2026-05-27 09:47:05 -04:00
async function createCardSubmission ( userId , fields , candidateIds = [ ] ) {
const ocrPayload = buildOcrPayload ( fields ) ;
const result = await sql `
INSERT INTO card _submissions (
user _id , ocr _text , ocr _confidence , scan _image _url ,
candidate _card _ids , ocr _payload , status
) VALUES (
$ { userId } ,
$ { fields . ocrData ? . rawText || fields . name || null } ,
$ { fields . ocrData ? . confidence ? ? null } ,
$ { fields . scanImageUrl || null } ,
$ { JSON . stringify ( candidateIds ) } ,
$ { JSON . stringify ( ocrPayload ) } ,
'pending'
)
RETURNING id
` ;
return result . rows [ 0 ] . id ;
}
/ * *
* Match OCR fields against the global cards catalog .
* Never INSERTs into cards — unknowns become card _submissions .
* /
export async function matchCardInCatalog ( {
userId ,
name ,
set ,
setCode ,
cardNumber ,
game ,
cardType ,
rarity ,
hp ,
manaCost ,
ocrData ,
scanImageUrl = null ,
} ) {
if ( ! name || typeof name !== 'string' || ! name . trim ( ) ) {
return {
type : 'needs_input' ,
card : null ,
matches : [ ] ,
needsUserInput : true ,
message : 'Card name is required' ,
} ;
}
const trimmedName = name . trim ( ) ;
let existingCard = null ;
if ( ( set || setCode ) && cardNumber ) {
const exactResult = await sql `
SELECT * FROM cards
WHERE LOWER ( name ) = LOWER ( $ { trimmedName } )
AND ( LOWER ( set _name ) = LOWER ( $ { set || setCode } ) OR LOWER ( set _code ) = LOWER ( $ { setCode || set } ) )
AND LOWER ( card _number ) = LOWER ( $ { cardNumber } )
LIMIT 1
` ;
if ( exactResult . rows . length > 0 ) {
existingCard = exactResult . rows [ 0 ] ;
2026-05-27 14:26:14 -04:00
} else {
return submitScanForReview (
userId ,
{
name : trimmedName ,
set ,
setCode ,
cardNumber ,
game ,
cardType ,
rarity ,
hp ,
manaCost ,
ocrData ,
scanImageUrl ,
} ,
[ ]
) ;
2026-05-27 09:47:05 -04:00
}
}
if ( ! existingCard && ( set || setCode ) ) {
const setResult = set
? await sql `
SELECT * FROM cards
WHERE LOWER ( name ) = LOWER ( $ { trimmedName } )
AND ( LOWER ( set _name ) = LOWER ( $ { set } ) OR LOWER ( set _code ) = LOWER ( $ { setCode || set } ) )
2026-05-27 14:10:46 -04:00
LIMIT 5
2026-05-27 09:47:05 -04:00
`
: await sql `
SELECT * FROM cards
WHERE LOWER ( name ) = LOWER ( $ { trimmedName } )
AND LOWER ( set _code ) = LOWER ( $ { setCode } )
2026-05-27 14:10:46 -04:00
LIMIT 5
2026-05-27 09:47:05 -04:00
` ;
2026-05-27 14:10:46 -04:00
if ( setResult . rows . length === 1 ) {
2026-05-27 09:47:05 -04:00
existingCard = setResult . rows [ 0 ] ;
2026-05-27 14:10:46 -04:00
} else if ( setResult . rows . length > 1 ) {
return {
type : 'disambiguation' ,
card : null ,
matches : setResult . rows . map ( mapCardRow ) ,
needsUserSelection : true ,
message : ` Found ${ setResult . rows . length } matches for " ${ trimmedName } " in that set. Select the correct printing. ` ,
} ;
2026-05-27 09:47:05 -04:00
}
}
if ( ! existingCard ) {
2026-05-27 14:10:46 -04:00
const nameResult = game
? await sql `
SELECT * FROM cards
WHERE LOWER ( name ) = LOWER ( $ { trimmedName } )
ORDER BY CASE WHEN game = $ { game } THEN 0 ELSE 1 END , set _name , card _number
`
: await sql `
SELECT * FROM cards
WHERE LOWER ( name ) = LOWER ( $ { trimmedName } )
ORDER BY set _name , card _number
` ;
if ( nameResult . rows . length === 1 ) {
2026-05-27 09:47:05 -04:00
existingCard = nameResult . rows [ 0 ] ;
2026-05-27 14:10:46 -04:00
} else if ( nameResult . rows . length > 1 ) {
return {
type : 'disambiguation' ,
card : null ,
matches : nameResult . rows . slice ( 0 , 8 ) . map ( mapCardRow ) ,
needsUserSelection : true ,
message : ` Found ${ nameResult . rows . length } printings of " ${ trimmedName } ". Select the correct card. ` ,
} ;
2026-05-27 09:47:05 -04:00
}
}
if ( ! existingCard ) {
const fuzzyResult = await sql `
SELECT * FROM cards
WHERE LOWER ( name ) ILIKE LOWER ( $ { ` % ${ trimmedName } % ` } )
ORDER BY
CASE
WHEN LOWER ( name ) = LOWER ( $ { trimmedName } ) THEN 1
WHEN LOWER ( name ) LIKE LOWER ( $ { trimmedName + '%' } ) THEN 2
WHEN LOWER ( name ) LIKE LOWER ( $ { '%' + trimmedName + '%' } ) THEN 3
ELSE 4
END ,
CASE WHEN game = $ { game || 'UNKNOWN' } THEN 1 ELSE 2 END ,
LENGTH ( name )
LIMIT 5
` ;
if ( fuzzyResult . rows . length > 0 ) {
const exactFuzzyMatch = fuzzyResult . rows . find (
( row ) => row . name . toLowerCase ( ) === trimmedName . toLowerCase ( )
) ;
if ( exactFuzzyMatch && ocrData ? . confidence >= 80 ) {
existingCard = exactFuzzyMatch ;
} else if ( ocrData ? . confidence < 80 && fuzzyResult . rows . length > 1 ) {
return {
type : 'disambiguation' ,
card : null ,
matches : fuzzyResult . rows . map ( mapCardRow ) ,
needsUserSelection : true ,
message : ` Found ${ fuzzyResult . rows . length } possible matches for " ${ trimmedName } ". Please select the correct card. ` ,
} ;
} else {
existingCard = fuzzyResult . rows [ 0 ] ;
}
}
}
if ( existingCard ) {
2026-05-27 14:26:14 -04:00
const hasSpecificPrinting = Boolean ( ( set || setCode ) && cardNumber ) ;
2026-05-27 14:10:46 -04:00
2026-05-27 14:26:14 -04:00
if ( hasSpecificPrinting ) {
const printingMatch = await sql `
SELECT * FROM cards
WHERE id = $ { existingCard . id }
AND (
LOWER ( card _number ) = LOWER ( $ { cardNumber } )
AND (
LOWER ( set _name ) = LOWER ( $ { set || setCode } )
OR LOWER ( set _code ) = LOWER ( $ { setCode || set } )
)
)
LIMIT 1
` ;
if ( printingMatch . rows . length === 0 ) {
const siblingIds = await sql `
SELECT id FROM cards WHERE LOWER ( name ) = LOWER ( $ { trimmedName } )
` ;
return submitScanForReview (
userId ,
{
name : trimmedName ,
set ,
setCode ,
cardNumber ,
game ,
cardType ,
rarity ,
hp ,
manaCost ,
ocrData ,
scanImageUrl ,
} ,
siblingIds . rows . map ( ( row ) => row . id )
) ;
}
} else {
const siblingsResult = await sql `
SELECT * FROM cards
WHERE LOWER ( name ) = LOWER ( $ { trimmedName } )
ORDER BY set _name , card _number
` ;
if ( siblingsResult . rows . length > 1 ) {
const ordered = [
existingCard ,
... siblingsResult . rows . filter ( ( row ) => row . id !== existingCard . id ) ,
] ;
return {
type : 'disambiguation' ,
card : null ,
matches : ordered . slice ( 0 , 8 ) . map ( mapCardRow ) ,
needsUserSelection : true ,
message : ` Found ${ siblingsResult . rows . length } printings of " ${ trimmedName } ". Confirm the correct one. ` ,
} ;
}
2026-05-27 14:10:46 -04:00
}
2026-05-27 09:47:05 -04:00
return {
type : 'matched' ,
card : existingCard ,
isExisting : true ,
message : ` Found existing card: " ${ existingCard . name } " ` ,
} ;
}
const confidenceThreshold = 75 ;
if ( ! ocrData || ocrData . confidence < confidenceThreshold ) {
return {
type : 'needs_input' ,
card : null ,
matches : [ ] ,
needsUserInput : true ,
message : ` Could not find card " ${ trimmedName } " in database and confidence is low ( ${ ocrData ? . confidence || 0 } %). Please verify the card name and try again. ` ,
} ;
}
2026-05-27 14:26:14 -04:00
return submitScanForReview (
2026-05-27 09:47:05 -04:00
userId ,
2026-05-27 14:26:14 -04:00
{
name : trimmedName ,
set ,
setCode ,
cardNumber ,
game ,
cardType ,
rarity ,
hp ,
manaCost ,
ocrData ,
scanImageUrl ,
} ,
2026-05-27 09:47:05 -04:00
[ ]
) ;
}
export async function logScanAttempt ( {
userId ,
ocrText ,
ocrConfidence ,
layer = 2 ,
matchedCardId = null ,
resultKind ,
latencyMs ,
} ) {
2026-05-27 13:42:37 -04:00
try {
await sql `
INSERT INTO scan _attempts (
user _id , ocr _text , ocr _confidence , layer ,
matched _card _id , result _kind , latency _ms
) VALUES (
$ { userId } ,
$ { ocrText || null } ,
$ { ocrConfidence ? ? null } ,
$ { layer } ,
$ { matchedCardId } ,
$ { resultKind } ,
$ { latencyMs ? ? null }
)
` ;
} catch ( error ) {
// Telemetry must not block identification (e.g. migration not yet applied).
console . error ( '[logScanAttempt]' , error . message ) ;
}
2026-05-27 09:47:05 -04:00
}