Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
615 lines
18 KiB
TypeScript
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);
|
|
};
|