// src/controllers/upc.controller.ts // ============================================================================ // UPC CONTROLLER // ============================================================================ // Provides endpoints for UPC barcode scanning, lookup, and management. // Implements endpoints for: // - Scanning a UPC barcode (manual entry or image) // - Looking up a UPC code // - Getting scan history // - Getting a single scan by ID // - Getting scan statistics // - Linking a UPC to a product (admin only) // // All UPC endpoints require authentication. // ============================================================================ import { Get, Post, Route, Tags, Path, Query, Body, Request, Security, SuccessResponse, Response, } from 'tsoa'; import type { Request as ExpressRequest } from 'express'; import { BaseController } from './base.controller'; import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types'; import * as upcService from '../services/upcService.server'; import type { UserProfile } from '../types'; import type { UpcScanSource } from '../types/upc'; // ============================================================================ // DTO TYPES FOR OPENAPI // ============================================================================ // Data Transfer Objects that are tsoa-compatible for API documentation. // ============================================================================ /** * Product match from our database. */ interface ProductMatchDto { /** Internal product ID */ product_id: number; /** Product name */ name: string; /** Brand name, if known */ brand: string | null; /** Product category */ category: string | null; /** Product description */ description: string | null; /** Product size/weight */ size: string | null; /** The UPC code */ upc_code: string; /** Product image URL */ image_url: string | null; /** Link to master grocery item */ master_item_id: number | null; } /** * Product information from external lookup. */ interface ExternalProductInfoDto { /** Product name from external source */ name: string; /** Brand name from external source */ brand: string | null; /** Product category from external source */ category: string | null; /** Product description from external source */ description: string | null; /** Product image URL from external source */ image_url: string | null; /** Which external API provided this data */ source: string; } /** * Complete result from a UPC scan operation. */ interface ScanResultDto { /** ID of the recorded scan */ scan_id: number; /** The scanned UPC code */ upc_code: string; /** Matched product from our database, if found */ product: ProductMatchDto | null; /** Product info from external lookup, if performed */ external_lookup: ExternalProductInfoDto | null; /** Confidence score of barcode detection (0.0-1.0) */ confidence: number | null; /** Whether any product info was found */ lookup_successful: boolean; /** Whether this UPC was not previously in our database */ is_new_product: boolean; /** Timestamp of the scan */ scanned_at: string; } /** * Result from a UPC lookup. */ interface LookupResultDto { /** The looked up UPC code */ upc_code: string; /** Matched product from our database, if found */ product: ProductMatchDto | null; /** Product info from external lookup, if performed */ external_lookup: ExternalProductInfoDto | null; /** Whether any product info was found */ found: boolean; /** Whether the lookup result came from cache */ from_cache: boolean; } /** * UPC scan history record. */ interface ScanHistoryRecordDto { /** Primary key */ scan_id: number; /** User who performed the scan */ user_id: string; /** The scanned UPC code */ upc_code: string; /** Matched product ID, if found */ product_id: number | null; /** How the scan was performed */ scan_source: string; /** Confidence score from barcode detection */ scan_confidence: number | null; /** Path to uploaded barcode image */ raw_image_path: string | null; /** Whether the lookup found product info */ lookup_successful: boolean; /** When the scan was recorded */ created_at: string; /** Last update timestamp */ updated_at: string; } /** * Scan history list with total count. */ interface ScanHistoryResponseDto { /** List of scan history records */ scans: ScanHistoryRecordDto[]; /** Total count for pagination */ total: number; } /** * User scan statistics. */ interface ScanStatsDto { /** Total number of scans performed */ total_scans: number; /** Number of scans that found product info */ successful_lookups: number; /** Number of unique products scanned */ unique_products: number; /** Number of scans today */ scans_today: number; /** Number of scans this week */ scans_this_week: number; } // ============================================================================ // REQUEST TYPES // ============================================================================ /** * Valid scan source types. */ type ScanSourceType = 'image_upload' | 'manual_entry' | 'phone_app' | 'camera_scan'; /** * Request body for scanning a UPC barcode. */ interface ScanUpcRequest { /** * UPC code entered manually (8-14 digits). * Either this or image_base64 must be provided. * @pattern ^[0-9]{8,14}$ * @example "012345678901" */ upc_code?: string; /** * Base64-encoded image containing a barcode. * Either this or upc_code must be provided. */ image_base64?: string; /** * How the scan was initiated. * @example "manual_entry" */ scan_source: ScanSourceType; } /** * Request body for linking a UPC to a product (admin only). */ interface LinkUpcRequest { /** * The UPC code to link (8-14 digits). * @pattern ^[0-9]{8,14}$ * @example "012345678901" */ upc_code: string; /** * The product ID to link the UPC to. * @isInt * @minimum 1 */ product_id: number; } // ============================================================================ // UPC CONTROLLER // ============================================================================ /** * Controller for UPC barcode scanning endpoints. * * All UPC endpoints require authentication. The link endpoint additionally * requires admin privileges. */ @Route('upc') @Tags('UPC Scanning') @Security('bearerAuth') export class UpcController extends BaseController { // ========================================================================== // SCAN ENDPOINTS // ========================================================================== /** * Scan a UPC barcode. * * Scans a UPC barcode either from a manually entered code or from an image. * Records the scan in history and returns product information if found. * If not found in our database, attempts to look up in external APIs. * * @summary Scan a UPC barcode * @param body Scan request with UPC code or image * @returns Complete scan result with product information */ @Post('scan') @SuccessResponse(200, 'Scan completed successfully') @Response(400, 'Invalid UPC code format or missing data') @Response(401, 'Unauthorized') public async scanUpc( @Body() body: ScanUpcRequest, @Request() req: ExpressRequest, ): Promise> { const userProfile = req.user as UserProfile; const userId = userProfile.user.user_id; req.log.info( { userId, scanSource: body.scan_source, hasUpc: !!body.upc_code, hasImage: !!body.image_base64, }, 'UPC scan request received', ); // Validate that at least one input method is provided if (!body.upc_code && !body.image_base64) { this.setStatus(400); throw new Error('Either upc_code or image_base64 must be provided.'); } const result = await upcService.scanUpc( userId, { upc_code: body.upc_code, image_base64: body.image_base64, scan_source: body.scan_source as UpcScanSource, }, req.log, ); req.log.info( { scanId: result.scan_id, upcCode: result.upc_code, found: result.lookup_successful }, 'UPC scan completed', ); return this.success(result as unknown as ScanResultDto); } // ========================================================================== // LOOKUP ENDPOINTS // ========================================================================== /** * Look up a UPC code. * * Looks up product information for a UPC code without recording in scan history. * Useful for verification or quick lookups. First checks our database, then * optionally queries external APIs if not found locally. * * @summary Look up a UPC code * @param upc_code UPC code to look up (8-14 digits) * @param include_external Whether to check external APIs if not found locally (default: true) * @param force_refresh Skip cache and perform fresh external lookup (default: false) * @returns Lookup result with product information */ @Get('lookup') @SuccessResponse(200, 'Lookup completed successfully') @Response(400, 'Invalid UPC code format') @Response(401, 'Unauthorized') public async lookupUpc( @Request() req: ExpressRequest, @Query() upc_code: string, @Query() include_external?: boolean, @Query() force_refresh?: boolean, ): Promise> { req.log.debug( { upcCode: upc_code, forceRefresh: force_refresh }, 'UPC lookup request received', ); const result = await upcService.lookupUpc( { upc_code, force_refresh: force_refresh ?? false, }, req.log, ); return this.success(result as unknown as LookupResultDto); } // ========================================================================== // HISTORY ENDPOINTS // ========================================================================== /** * Get scan history. * * Retrieves the authenticated user's UPC scan history with optional filtering. * Results are ordered by scan date (newest first). * * @summary Get user's scan history * @param limit Maximum number of results (1-100, default: 50) * @param offset Number of results to skip (default: 0) * @param lookup_successful Filter by lookup success status * @param scan_source Filter by scan source * @param from_date Filter scans from this date (YYYY-MM-DD) * @param to_date Filter scans until this date (YYYY-MM-DD) * @returns Paginated scan history */ @Get('history') @SuccessResponse(200, 'Scan history retrieved successfully') @Response(401, 'Unauthorized') public async getScanHistory( @Request() req: ExpressRequest, @Query() limit?: number, @Query() offset?: number, @Query() lookup_successful?: boolean, @Query() scan_source?: ScanSourceType, @Query() from_date?: string, @Query() to_date?: string, ): Promise> { const userProfile = req.user as UserProfile; const userId = userProfile.user.user_id; // Apply defaults and bounds const normalizedLimit = Math.min(100, Math.max(1, Math.floor(limit ?? 50))); const normalizedOffset = Math.max(0, Math.floor(offset ?? 0)); req.log.debug( { userId, limit: normalizedLimit, offset: normalizedOffset }, 'Fetching scan history', ); const result = await upcService.getScanHistory( { user_id: userId, limit: normalizedLimit, offset: normalizedOffset, lookup_successful, scan_source: scan_source as UpcScanSource | undefined, from_date, to_date, }, req.log, ); return this.success(result as unknown as ScanHistoryResponseDto); } /** * Get scan by ID. * * Retrieves a specific scan record by its ID. * Only returns scans belonging to the authenticated user. * * @summary Get a specific scan record * @param scanId The unique identifier of the scan * @returns The scan record */ @Get('history/{scanId}') @SuccessResponse(200, 'Scan record retrieved successfully') @Response(401, 'Unauthorized') @Response(404, 'Scan record not found') public async getScanById( @Path() scanId: number, @Request() req: ExpressRequest, ): Promise> { const userProfile = req.user as UserProfile; const userId = userProfile.user.user_id; req.log.debug({ scanId, userId }, 'Fetching scan by ID'); const scan = await upcService.getScanById(scanId, userId, req.log); return this.success(scan as unknown as ScanHistoryRecordDto); } // ========================================================================== // STATISTICS ENDPOINTS // ========================================================================== /** * Get scan statistics. * * Returns scanning statistics for the authenticated user including * total scans, success rate, and activity metrics. * * @summary Get user's scan statistics * @returns Scan statistics */ @Get('stats') @SuccessResponse(200, 'Statistics retrieved successfully') @Response(401, 'Unauthorized') public async getScanStats( @Request() req: ExpressRequest, ): Promise> { const userProfile = req.user as UserProfile; const userId = userProfile.user.user_id; req.log.debug({ userId }, 'Fetching scan statistics'); const stats = await upcService.getScanStats(userId, req.log); return this.success(stats); } // ========================================================================== // ADMIN ENDPOINTS // ========================================================================== /** * Link UPC to product. * * Links a UPC code to an existing product in the database. * This is an admin-only operation used for data management. * * @summary Link a UPC code to a product (admin only) * @param body UPC code and product ID to link */ @Post('link') @Security('bearerAuth', ['admin']) @SuccessResponse(204, 'UPC linked successfully') @Response(400, 'Invalid UPC code format') @Response(401, 'Unauthorized') @Response(403, 'Forbidden - admin access required') @Response(404, 'Product not found') @Response(409, 'UPC code already linked to another product') public async linkUpcToProduct( @Body() body: LinkUpcRequest, @Request() req: ExpressRequest, ): Promise { const userProfile = req.user as UserProfile; req.log.info( { userId: userProfile.user.user_id, productId: body.product_id, upcCode: body.upc_code }, 'UPC link request received', ); await upcService.linkUpcToProduct(body.product_id, body.upc_code, req.log); req.log.info( { productId: body.product_id, upcCode: body.upc_code }, 'UPC code linked successfully', ); return this.noContent(); } }