// 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 | 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 => { 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 | 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 => { 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 => { logger.debug({ scanId, userId }, 'Fetching scan by ID'); return upcRepo.getScanById(scanId, userId, logger); };