Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
503 lines
15 KiB
TypeScript
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();
|
|
}
|
|
}
|