Files
flyer-crawler.projectium.com/src/controllers/upc.controller.ts
Torben Sorensen 2d2cd52011
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
Massive Dependency Modernization Project
2026-02-13 00:34:22 -08:00

503 lines
15 KiB
TypeScript

// 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<ErrorResponse>(400, 'Invalid UPC code format or missing data')
@Response<ErrorResponse>(401, 'Unauthorized')
public async scanUpc(
@Body() body: ScanUpcRequest,
@Request() req: ExpressRequest,
): Promise<SuccessResponseType<ScanResultDto>> {
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<ErrorResponse>(400, 'Invalid UPC code format')
@Response<ErrorResponse>(401, 'Unauthorized')
public async lookupUpc(
@Request() req: ExpressRequest,
@Query() upc_code: string,
@Query() include_external?: boolean,
@Query() force_refresh?: boolean,
): Promise<SuccessResponseType<LookupResultDto>> {
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<ErrorResponse>(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<SuccessResponseType<ScanHistoryResponseDto>> {
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<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(404, 'Scan record not found')
public async getScanById(
@Path() scanId: number,
@Request() req: ExpressRequest,
): Promise<SuccessResponseType<ScanHistoryRecordDto>> {
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<ErrorResponse>(401, 'Unauthorized')
public async getScanStats(
@Request() req: ExpressRequest,
): Promise<SuccessResponseType<ScanStatsDto>> {
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<ErrorResponse>(400, 'Invalid UPC code format')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - admin access required')
@Response<ErrorResponse>(404, 'Product not found')
@Response<ErrorResponse>(409, 'UPC code already linked to another product')
public async linkUpcToProduct(
@Body() body: LinkUpcRequest,
@Request() req: ExpressRequest,
): Promise<void> {
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();
}
}