Feature: barcode lookup with auto-fill and owned detection

This commit is contained in:
Ponte
2026-02-14 22:46:30 +01:00
parent 80a126bd6e
commit 832004b591
3 changed files with 233 additions and 8 deletions

View File

@@ -120,6 +120,7 @@ async function runMigrations() {
estimated_value NUMERIC(10,2) CHECK (estimated_value IS NULL OR estimated_value >= 0),
condition_score NUMERIC(4,2),
loaned_to TEXT,
barcode TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -128,6 +129,7 @@ async function runMigrations() {
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS is_duplicate BOOLEAN NOT NULL DEFAULT FALSE;");
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS purchase_price NUMERIC(10,2);");
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS condition_score NUMERIC(4,2);");
await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS barcode TEXT;");
await pool.query(`
CREATE TABLE IF NOT EXISTS backup_snapshots (
@@ -245,6 +247,7 @@ async function getCatalogFull() {
g.estimated_value,
g.condition_score,
g.loaned_to,
g.barcode,
g.created_at
FROM games g
JOIN consoles c ON c.id = g.console_id
@@ -276,6 +279,7 @@ async function getCatalogFull() {
value: row.estimated_value != null ? Number(row.estimated_value) : null,
condition: row.condition_score != null ? Number(row.condition_score) : null,
loanedTo: row.loaned_to || "",
barcode: row.barcode || "",
createdAt: row.created_at,
});
}
@@ -315,6 +319,7 @@ async function exportCatalogDumpWithClient(client) {
g.estimated_value,
g.condition_score,
g.loaned_to,
g.barcode,
g.created_at,
g.updated_at
FROM games g
@@ -354,6 +359,7 @@ async function exportCatalogDumpWithClient(client) {
value: row.estimated_value != null ? Number(row.estimated_value) : null,
condition: row.condition_score != null ? Number(row.condition_score) : null,
loanedTo: row.loaned_to || "",
barcode: row.barcode || "",
createdAt: row.created_at,
updatedAt: row.updated_at,
})),
@@ -497,6 +503,7 @@ async function restoreCatalogDump(mode, dump) {
const genre = normalizeText(gameEntry && gameEntry.genre) || null;
const publisher = normalizeText(gameEntry && gameEntry.publisher) || null;
const loanedTo = normalizeText(gameEntry && gameEntry.loanedTo) || null;
const barcode = normalizeText(gameEntry && gameEntry.barcode) || null;
const isDuplicate = Boolean(gameEntry && gameEntry.isDuplicate);
const purchasePrice =
gameEntry && gameEntry.purchasePrice != null && gameEntry.purchasePrice !== ""
@@ -513,7 +520,7 @@ async function restoreCatalogDump(mode, dump) {
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
estimated_value, condition_score, loaned_to, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, COALESCE($12::timestamptz, NOW()));
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13::timestamptz, NOW()));
`,
[
consoleId,
@@ -527,6 +534,7 @@ async function restoreCatalogDump(mode, dump) {
value,
condition,
loanedTo,
barcode,
createdAt,
],
);
@@ -580,6 +588,7 @@ async function createGame(payload) {
const version = normalizeText(payload.version) || null;
const isDuplicate = Boolean(payload.isDuplicate);
const loanedTo = normalizeText(payload.loanedTo) || null;
const barcode = normalizeText(payload.barcode) || null;
const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null;
const purchasePrice =
payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null;
@@ -590,12 +599,25 @@ async function createGame(payload) {
`
INSERT INTO games(
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
estimated_value, condition_score, loaned_to
estimated_value, condition_score, loaned_to, barcode
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id::text AS id;
`,
[consoleData.consoleId, title, genre, publisher, version, isDuplicate, year, purchasePrice, value, condition, loanedTo],
[
consoleData.consoleId,
title,
genre,
publisher,
version,
isDuplicate,
year,
purchasePrice,
value,
condition,
loanedTo,
barcode,
],
);
await client.query("COMMIT");
@@ -624,6 +646,7 @@ async function updateGame(id, payload) {
const version = normalizeText(payload.version) || null;
const isDuplicate = Boolean(payload.isDuplicate);
const loanedTo = normalizeText(payload.loanedTo) || null;
const barcode = normalizeText(payload.barcode) || null;
const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null;
const purchasePrice =
payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null;
@@ -644,7 +667,8 @@ async function updateGame(id, payload) {
purchase_price = $9,
estimated_value = $10,
condition_score = $11,
loaned_to = $12
loaned_to = $12,
barcode = $13
WHERE id = $1::uuid
RETURNING id::text AS id;
`,
@@ -661,6 +685,7 @@ async function updateGame(id, payload) {
value,
condition,
loanedTo,
barcode,
],
);
@@ -696,6 +721,108 @@ async function toggleGameLoan(id) {
return result.rowCount > 0;
}
function normalizeBarcode(rawValue) {
const trimmed = normalizeText(rawValue);
return trimmed.replace(/[^\dA-Za-z-]/g, "");
}
async function lookupOwnedByBarcode(barcode) {
const result = await pool.query(
`
SELECT
g.id::text AS id,
g.title,
g.publisher,
g.game_version,
g.barcode,
c.name AS console_name,
b.name AS brand_name
FROM games g
JOIN consoles c ON c.id = g.console_id
JOIN brands b ON b.id = c.brand_id
WHERE g.barcode = $1
ORDER BY g.created_at DESC
LIMIT 1;
`,
[barcode],
);
if (!result.rowCount) {
return null;
}
const row = result.rows[0];
return {
id: row.id,
title: row.title,
publisher: row.publisher || "",
version: row.game_version || "",
barcode: row.barcode || "",
consoleName: row.console_name,
brand: row.brand_name,
};
}
async function lookupBarcodeExternal(barcode) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4500);
try {
const response = await fetch(`https://api.upcitemdb.com/prod/trial/lookup?upc=${encodeURIComponent(barcode)}`, {
method: "GET",
signal: controller.signal,
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
return null;
}
const payload = await response.json();
const item = payload && Array.isArray(payload.items) ? payload.items[0] : null;
if (!item) {
return null;
}
const title = normalizeText(item.title);
if (!title) {
return null;
}
return {
title,
publisher: normalizeText(item.brand),
source: "upcitemdb",
};
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
async function lookupBarcode(barcodeRaw) {
const barcode = normalizeBarcode(barcodeRaw);
if (!barcode || barcode.length < 8) {
throw new Error("invalid barcode");
}
const ownedGame = await lookupOwnedByBarcode(barcode);
if (ownedGame) {
return {
barcode,
owned: true,
game: ownedGame,
lookup: null,
};
}
const external = await lookupBarcodeExternal(barcode);
return {
barcode,
owned: false,
game: null,
lookup: external,
};
}
async function importCatalog(payload) {
const brands = payload && payload.brands && typeof payload.brands === "object" ? payload.brands : {};
const gamesByConsole =
@@ -776,6 +903,7 @@ async function importCatalog(payload) {
const genre = normalizeText(game && game.genre) || null;
const publisher = normalizeText(game && game.publisher) || null;
const loanedTo = normalizeText(game && game.loanedTo) || null;
const barcode = normalizeText(game && game.barcode) || null;
const year = game && game.year != null && game.year !== "" ? Number(game.year) : null;
const purchasePrice =
game && game.purchasePrice != null && game.purchasePrice !== "" ? Number(game.purchasePrice) : null;
@@ -803,11 +931,24 @@ async function importCatalog(payload) {
`
INSERT INTO games(
console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price,
estimated_value, condition_score, loaned_to
estimated_value, condition_score, loaned_to, barcode
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
`,
[consoleId, title, genre, publisher, version || null, isDuplicate, year, purchasePrice, value, condition, loanedTo],
[
consoleId,
title,
genre,
publisher,
version || null,
isDuplicate,
year,
purchasePrice,
value,
condition,
loanedTo,
barcode,
],
);
insertedGames += 1;
@@ -1229,6 +1370,18 @@ async function handleRequest(request, response) {
return;
}
const barcodeLookupMatch = url.pathname.match(/^\/api\/barcode\/lookup\/([^/]+)$/);
if (request.method === "GET" && barcodeLookupMatch) {
try {
const result = await lookupBarcode(decodeURIComponent(barcodeLookupMatch[1]));
sendJson(response, 200, { status: "ok", ...result });
} catch (error) {
const statusCode = error.message === "invalid barcode" ? 400 : 500;
sendJson(response, statusCode, { status: "error", message: error.message });
}
return;
}
const gameIdMatch = url.pathname.match(/^\/api\/catalog\/games\/([0-9a-fA-F-]+)$/);
if (request.method === "PUT" && gameIdMatch) {
try {