Files
flyer-crawler.projectium.com/src/services/upcService.server.ts
Torben Sorensen 11aeac5edd
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
whoa - so much - new features (UPC,etc) - Sentry for app logging! so much more !
2026-01-11 19:07:02 -08:00

615 lines
18 KiB
TypeScript

// src/services/upcService.server.ts
/**
* @file UPC Scanning Service
* Handles UPC barcode scanning, lookup, and external API integration.
* Provides functionality for scanning barcodes from images and manual entry.
*/
import type { Logger } from 'pino';
import { upcRepo } from './db/index.db';
import type {
UpcScanRequest,
UpcScanResult,
UpcLookupResult,
UpcProductMatch,
UpcExternalProductInfo,
UpcExternalLookupOptions,
UpcScanHistoryQueryOptions,
UpcScanHistoryRecord,
BarcodeDetectionResult,
} from '../types/upc';
import { config, isUpcItemDbConfigured, isBarcodeLookupConfigured } from '../config/env';
/**
* Default cache age for external lookups (7 days in hours)
*/
const DEFAULT_CACHE_AGE_HOURS = 168;
/**
* UPC code validation regex (8-14 digits)
*/
const UPC_CODE_REGEX = /^[0-9]{8,14}$/;
/**
* Validates a UPC code format.
* @param upcCode The UPC code to validate
* @returns True if the UPC code is valid, false otherwise
*/
export const isValidUpcCode = (upcCode: string): boolean => {
return UPC_CODE_REGEX.test(upcCode);
};
/**
* Normalizes a UPC code by removing spaces and dashes.
* @param upcCode The raw UPC code input
* @returns Normalized UPC code
*/
export const normalizeUpcCode = (upcCode: string): string => {
return upcCode.replace(/[\s-]/g, '');
};
/**
* Detects and decodes a barcode from an image.
* This is a placeholder for actual barcode detection implementation.
* In production, this would use a library like zxing-js, quagga, or an external service.
* @param imageBase64 Base64-encoded image data
* @param logger Pino logger instance
* @returns Barcode detection result
*/
export const detectBarcodeFromImage = async (
imageBase64: string,
logger: Logger,
): Promise<BarcodeDetectionResult> => {
logger.debug({ imageLength: imageBase64.length }, 'Attempting to detect barcode from image');
// TODO: Implement actual barcode detection using a library like:
// - @nickvdyck/barcode-reader (pure JS)
// - dynamsoft-javascript-barcode (commercial)
// - External service like Google Cloud Vision API
//
// For now, return a placeholder response indicating detection is not yet implemented
logger.warn('Barcode detection from images is not yet implemented');
return {
detected: false,
upc_code: null,
confidence: null,
format: null,
error: 'Barcode detection from images is not yet implemented. Please use manual entry.',
};
};
/**
* Looks up product in Open Food Facts API (free, open source).
* @param upcCode The UPC code to look up
* @param logger Pino logger instance
* @returns External product information or null if not found
*/
const lookupOpenFoodFacts = async (
upcCode: string,
logger: Logger,
): Promise<UpcExternalProductInfo | null> => {
try {
const openFoodFactsUrl = `https://world.openfoodfacts.org/api/v2/product/${upcCode}`;
logger.debug({ url: openFoodFactsUrl }, 'Querying Open Food Facts API');
const response = await fetch(openFoodFactsUrl, {
headers: {
'User-Agent': 'FlyerCrawler/1.0 (contact@projectium.com)',
},
});
if (response.ok) {
const data = await response.json();
if (data.status === 1 && data.product) {
const product = data.product;
logger.info(
{ upcCode, productName: product.product_name },
'Found product in Open Food Facts',
);
return {
name: product.product_name || product.generic_name || 'Unknown Product',
brand: product.brands || null,
category: product.categories_tags?.[0]?.replace('en:', '') || null,
description: product.ingredients_text || null,
image_url: product.image_url || product.image_front_url || null,
source: 'openfoodfacts',
raw_data: product,
};
}
}
logger.debug({ upcCode }, 'Product not found in Open Food Facts');
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.warn({ err, upcCode }, 'Error querying Open Food Facts API');
}
return null;
};
/**
* Looks up product in UPC Item DB API.
* Requires UPC_ITEM_DB_API_KEY environment variable.
* @see https://www.upcitemdb.com/wp/docs/main/development/
* @param upcCode The UPC code to look up
* @param logger Pino logger instance
* @returns External product information or null if not found
*/
const lookupUpcItemDb = async (
upcCode: string,
logger: Logger,
): Promise<UpcExternalProductInfo | null> => {
if (!isUpcItemDbConfigured) {
logger.debug('UPC Item DB API key not configured, skipping');
return null;
}
try {
const url = `https://api.upcitemdb.com/prod/trial/lookup?upc=${upcCode}`;
logger.debug({ url }, 'Querying UPC Item DB API');
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
user_key: config.upc.upcItemDbApiKey!,
key_type: '3scale',
},
});
if (response.ok) {
const data = await response.json();
if (data.code === 'OK' && data.items && data.items.length > 0) {
const item = data.items[0];
logger.info({ upcCode, productName: item.title }, 'Found product in UPC Item DB');
return {
name: item.title || 'Unknown Product',
brand: item.brand || null,
category: item.category || null,
description: item.description || null,
image_url: item.images?.[0] || null,
source: 'upcitemdb',
raw_data: item,
};
}
} else if (response.status === 429) {
logger.warn({ upcCode }, 'UPC Item DB rate limit exceeded');
}
logger.debug({ upcCode }, 'Product not found in UPC Item DB');
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.warn({ err, upcCode }, 'Error querying UPC Item DB API');
}
return null;
};
/**
* Looks up product in Barcode Lookup API.
* Requires BARCODE_LOOKUP_API_KEY environment variable.
* @see https://www.barcodelookup.com/api
* @param upcCode The UPC code to look up
* @param logger Pino logger instance
* @returns External product information or null if not found
*/
const lookupBarcodeLookup = async (
upcCode: string,
logger: Logger,
): Promise<UpcExternalProductInfo | null> => {
if (!isBarcodeLookupConfigured) {
logger.debug('Barcode Lookup API key not configured, skipping');
return null;
}
try {
const url = `https://api.barcodelookup.com/v3/products?barcode=${upcCode}&key=${config.upc.barcodeLookupApiKey}`;
logger.debug('Querying Barcode Lookup API');
const response = await fetch(url, {
headers: {
Accept: 'application/json',
},
});
if (response.ok) {
const data = await response.json();
if (data.products && data.products.length > 0) {
const product = data.products[0];
logger.info({ upcCode, productName: product.title }, 'Found product in Barcode Lookup');
return {
name: product.title || product.product_name || 'Unknown Product',
brand: product.brand || null,
category: product.category || null,
description: product.description || null,
image_url: product.images?.[0] || null,
source: 'barcodelookup',
raw_data: product,
};
}
} else if (response.status === 429) {
logger.warn({ upcCode }, 'Barcode Lookup rate limit exceeded');
} else if (response.status === 404) {
logger.debug({ upcCode }, 'Product not found in Barcode Lookup');
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.warn({ err, upcCode }, 'Error querying Barcode Lookup API');
}
return null;
};
/**
* Looks up product information from external UPC databases.
* Tries multiple APIs in order of preference:
* 1. Open Food Facts (free, open source)
* 2. UPC Item DB (requires API key)
* 3. Barcode Lookup (requires API key)
* @param upcCode The UPC code to look up
* @param logger Pino logger instance
* @returns External product information or null if not found
*/
export const lookupExternalUpc = async (
upcCode: string,
logger: Logger,
): Promise<UpcExternalProductInfo | null> => {
logger.debug({ upcCode }, 'Looking up UPC in external databases');
// Try Open Food Facts first (free, no API key needed)
let result = await lookupOpenFoodFacts(upcCode, logger);
if (result) {
return result;
}
// Try UPC Item DB if configured
result = await lookupUpcItemDb(upcCode, logger);
if (result) {
return result;
}
// Try Barcode Lookup if configured
result = await lookupBarcodeLookup(upcCode, logger);
if (result) {
return result;
}
logger.debug({ upcCode }, 'No external product information found');
return null;
};
/**
* Performs a UPC scan operation including barcode detection, database lookup,
* and optional external API lookup.
* @param userId The user performing the scan
* @param request The scan request containing UPC code or image
* @param logger Pino logger instance
* @returns Complete scan result with product information
*/
export const scanUpc = async (
userId: string,
request: UpcScanRequest,
logger: Logger,
): Promise<UpcScanResult> => {
const scanLogger = logger.child({ userId, scanSource: request.scan_source });
scanLogger.info('Starting UPC scan');
let upcCode: string | null = null;
let scanConfidence: number | null = null;
// Step 1: Get UPC code from request (manual entry or image detection)
if (request.upc_code) {
// Manual entry - normalize and validate
upcCode = normalizeUpcCode(request.upc_code);
if (!isValidUpcCode(upcCode)) {
scanLogger.warn({ upcCode }, 'Invalid UPC code format');
throw new Error('Invalid UPC code format. UPC codes must be 8-14 digits.');
}
scanConfidence = 1.0; // Manual entry has 100% confidence
scanLogger.debug({ upcCode }, 'Using manually entered UPC code');
} else if (request.image_base64) {
// Image detection
const detection = await detectBarcodeFromImage(request.image_base64, scanLogger);
if (!detection.detected || !detection.upc_code) {
// Record the failed scan attempt
const scanRecord = await upcRepo.recordScan(
userId,
'DETECTION_FAILED',
request.scan_source,
scanLogger,
{
scanConfidence: 0,
lookupSuccessful: false,
},
);
return {
scan_id: scanRecord.scan_id,
upc_code: '',
product: null,
external_lookup: null,
confidence: 0,
lookup_successful: false,
is_new_product: false,
scanned_at: scanRecord.created_at,
};
}
upcCode = detection.upc_code;
scanConfidence = detection.confidence;
scanLogger.info({ upcCode, confidence: scanConfidence }, 'Barcode detected from image');
} else {
throw new Error('Either upc_code or image_base64 must be provided.');
}
// Step 2: Look up product in our database
let product: UpcProductMatch | null = null;
product = await upcRepo.findProductByUpc(upcCode, scanLogger);
const isNewProduct = !product;
scanLogger.debug({ upcCode, found: !!product, isNewProduct }, 'Local database lookup complete');
// Step 3: If not found locally, check external APIs
let externalLookup: UpcExternalProductInfo | null = null;
if (!product) {
// Check cache first
const cachedLookup = await upcRepo.findExternalLookup(
upcCode,
DEFAULT_CACHE_AGE_HOURS,
scanLogger,
);
if (cachedLookup) {
scanLogger.debug({ upcCode }, 'Using cached external lookup');
if (cachedLookup.lookup_successful) {
externalLookup = {
name: cachedLookup.product_name || 'Unknown Product',
brand: cachedLookup.brand_name,
category: cachedLookup.category,
description: cachedLookup.description,
image_url: cachedLookup.image_url,
source: cachedLookup.external_source,
raw_data: cachedLookup.lookup_data ?? undefined,
};
}
} else {
// Perform fresh external lookup
externalLookup = await lookupExternalUpc(upcCode, scanLogger);
// Cache the result (success or failure)
await upcRepo.upsertExternalLookup(
upcCode,
externalLookup?.source || 'unknown',
!!externalLookup,
scanLogger,
externalLookup
? {
productName: externalLookup.name,
brandName: externalLookup.brand,
category: externalLookup.category,
description: externalLookup.description,
imageUrl: externalLookup.image_url,
lookupData: externalLookup.raw_data as Record<string, unknown> | undefined,
}
: {},
);
}
}
// Step 4: Record the scan in history
const lookupSuccessful = !!(product || externalLookup);
const scanRecord = await upcRepo.recordScan(userId, upcCode, request.scan_source, scanLogger, {
productId: product?.product_id,
scanConfidence,
lookupSuccessful,
});
scanLogger.info(
{ scanId: scanRecord.scan_id, upcCode, lookupSuccessful, isNewProduct },
'UPC scan completed',
);
return {
scan_id: scanRecord.scan_id,
upc_code: upcCode,
product,
external_lookup: externalLookup,
confidence: scanConfidence,
lookup_successful: lookupSuccessful,
is_new_product: isNewProduct,
scanned_at: scanRecord.created_at,
};
};
/**
* Looks up a UPC code without recording scan history.
* Useful for quick lookups or verification.
* @param options Lookup options
* @param logger Pino logger instance
* @returns Lookup result with product information
*/
export const lookupUpc = async (
options: UpcExternalLookupOptions,
logger: Logger,
): Promise<UpcLookupResult> => {
const {
upc_code,
force_refresh = false,
max_cache_age_hours = DEFAULT_CACHE_AGE_HOURS,
} = options;
const lookupLogger = logger.child({ upcCode: upc_code });
lookupLogger.debug('Performing UPC lookup');
const normalizedUpc = normalizeUpcCode(upc_code);
if (!isValidUpcCode(normalizedUpc)) {
throw new Error('Invalid UPC code format. UPC codes must be 8-14 digits.');
}
// Check local database
const product = await upcRepo.findProductByUpc(normalizedUpc, lookupLogger);
if (product) {
lookupLogger.debug({ productId: product.product_id }, 'Found product in local database');
return {
upc_code: normalizedUpc,
product,
external_lookup: null,
found: true,
from_cache: false,
};
}
// Check external cache (unless force refresh)
if (!force_refresh) {
const cachedLookup = await upcRepo.findExternalLookup(
normalizedUpc,
max_cache_age_hours,
lookupLogger,
);
if (cachedLookup) {
lookupLogger.debug('Returning cached external lookup');
if (cachedLookup.lookup_successful) {
return {
upc_code: normalizedUpc,
product: null,
external_lookup: {
name: cachedLookup.product_name || 'Unknown Product',
brand: cachedLookup.brand_name,
category: cachedLookup.category,
description: cachedLookup.description,
image_url: cachedLookup.image_url,
source: cachedLookup.external_source,
raw_data: cachedLookup.lookup_data ?? undefined,
},
found: true,
from_cache: true,
};
}
// Cached lookup was unsuccessful
return {
upc_code: normalizedUpc,
product: null,
external_lookup: null,
found: false,
from_cache: true,
};
}
}
// Perform fresh external lookup
const externalLookup = await lookupExternalUpc(normalizedUpc, lookupLogger);
// Cache the result
await upcRepo.upsertExternalLookup(
normalizedUpc,
externalLookup?.source || 'unknown',
!!externalLookup,
lookupLogger,
externalLookup
? {
productName: externalLookup.name,
brandName: externalLookup.brand,
category: externalLookup.category,
description: externalLookup.description,
imageUrl: externalLookup.image_url,
lookupData: externalLookup.raw_data as Record<string, unknown> | undefined,
}
: {},
);
return {
upc_code: normalizedUpc,
product: null,
external_lookup: externalLookup,
found: !!externalLookup,
from_cache: false,
};
};
/**
* Links a UPC code to an existing product (admin operation).
* @param productId The product ID to link
* @param upcCode The UPC code to link
* @param logger Pino logger instance
*/
export const linkUpcToProduct = async (
productId: number,
upcCode: string,
logger: Logger,
): Promise<void> => {
const normalizedUpc = normalizeUpcCode(upcCode);
if (!isValidUpcCode(normalizedUpc)) {
throw new Error('Invalid UPC code format. UPC codes must be 8-14 digits.');
}
logger.info({ productId, upcCode: normalizedUpc }, 'Linking UPC code to product');
await upcRepo.linkUpcToProduct(productId, normalizedUpc, logger);
logger.info({ productId, upcCode: normalizedUpc }, 'UPC code linked successfully');
};
/**
* Gets the scan history for a user.
* @param options Query options
* @param logger Pino logger instance
* @returns Paginated scan history
*/
export const getScanHistory = async (
options: UpcScanHistoryQueryOptions,
logger: Logger,
): Promise<{ scans: UpcScanHistoryRecord[]; total: number }> => {
logger.debug({ userId: options.user_id }, 'Fetching scan history');
return upcRepo.getScanHistory(options, logger);
};
/**
* Gets scan statistics for a user.
* @param userId The user ID
* @param logger Pino logger instance
* @returns Scan statistics
*/
export const getScanStats = async (
userId: string,
logger: Logger,
): Promise<{
total_scans: number;
successful_lookups: number;
unique_products: number;
scans_today: number;
scans_this_week: number;
}> => {
logger.debug({ userId }, 'Fetching scan statistics');
return upcRepo.getUserScanStats(userId, logger);
};
/**
* Gets a single scan record by ID.
* @param scanId The scan ID
* @param userId The user ID (for authorization)
* @param logger Pino logger instance
* @returns The scan record
*/
export const getScanById = async (
scanId: number,
userId: string,
logger: Logger,
): Promise<UpcScanHistoryRecord> => {
logger.debug({ scanId, userId }, 'Fetching scan by ID');
return upcRepo.getScanById(scanId, userId, logger);
};