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 ,
} ;
}
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 ] ;
}
}
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 } ) )
LIMIT 1
`
: await sql `
SELECT * FROM cards
WHERE LOWER ( name ) = LOWER ( $ { trimmedName } )
AND LOWER ( set _code ) = LOWER ( $ { setCode } )
LIMIT 1
` ;
if ( setResult . rows . length > 0 ) {
existingCard = setResult . rows [ 0 ] ;
}
}
if ( ! existingCard ) {
const nameResult = await sql `
SELECT * FROM cards
WHERE LOWER ( name ) = LOWER ( $ { trimmedName } )
ORDER BY
CASE WHEN game = $ { game || 'UNKNOWN' } THEN 1 ELSE 2 END ,
created _at DESC
LIMIT 1
` ;
if ( nameResult . rows . length > 0 ) {
existingCard = nameResult . rows [ 0 ] ;
}
}
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 ) {
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. ` ,
} ;
}
const submissionId = await createCardSubmission (
userId ,
{ name : trimmedName , set , setCode , cardNumber , game , cardType , rarity , hp , manaCost , ocrData , scanImageUrl } ,
[ ]
) ;
return {
type : 'submitted' ,
card : null ,
submissionId ,
needsReview : true ,
message : ` Card " ${ trimmedName } " was not found in the catalog. Your scan was saved for admin review (submission # ${ submissionId } ). ` ,
} ;
}
export async function logScanAttempt ( {
userId ,
ocrText ,
ocrConfidence ,
layer = 2 ,
matchedCardId = null ,
resultKind ,
latencyMs ,
} ) {
2026-05-27 13:41:02 -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
}