// src/services/flyerDataTransformer.ts import path from 'path'; import type { z } from 'zod'; import type { Logger } from 'pino'; import type { FlyerInsert, FlyerItemInsert } from '../types'; import type { AiProcessorResult } from './flyerAiProcessor.server'; // Keep this import for AiProcessorResult import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema import { TransformationError } from './processingErrors'; import { parsePriceToCents } from '../utils/priceParser'; import { getBaseUrl } from '../utils/serverUtils'; /** * This class is responsible for transforming the validated data from the AI service * into the structured format required for database insertion (FlyerInsert and FlyerItemInsert). */ export class FlyerDataTransformer { /** * Normalizes a single raw item from the AI, providing default values for nullable fields. * @param item The raw item object from the AI. * @returns A normalized item object ready for database insertion. */ private _normalizeItem( item: z.infer['items'][number], ): FlyerItemInsert { // If the AI fails to provide `price_in_cents` but provides a parsable `price_display`, // we can use our own parser as a fallback to improve data quality. const priceFromDisplay = parsePriceToCents(item.price_display ?? ''); // Prioritize the AI's direct `price_in_cents` value, but use the parsed value if the former is null. const finalPriceInCents = item.price_in_cents ?? priceFromDisplay; return { ...item, // Use nullish coalescing and trim for robustness. // An empty or whitespace-only name falls back to 'Unknown Item'. item: (String(item.item ?? '')).trim() || 'Unknown Item', // Default null/undefined to an empty string and trim. price_display: (String(item.price_display ?? '')).trim(), quantity: (String(item.quantity ?? '')).trim(), // An empty or whitespace-only category falls back to 'Other/Miscellaneous'. category_name: (String(item.category_name ?? '')).trim() || 'Other/Miscellaneous', // Overwrite price_in_cents with our calculated value. price_in_cents: finalPriceInCents, // Use nullish coalescing to convert null to undefined for the database. master_item_id: item.master_item_id ?? undefined, view_count: 0, click_count: 0, }; } /** * Constructs the full public URLs for the flyer image and its icon. * @param imageFileName The filename of the main processed image. * @param iconFileName The filename of the generated icon. * @param baseUrl The base URL from the job payload. * @param logger The logger instance. * @returns An object containing the full image_url and icon_url. */ private _buildUrls( imageFileName: string, iconFileName: string, baseUrl: string, logger: Logger, ): { imageUrl: string; iconUrl: string } { console.error('[DEBUG] FlyerDataTransformer._buildUrls inputs:', { imageFileName, iconFileName, baseUrl }); logger.debug({ imageFileName, iconFileName, baseUrl }, 'Building URLs'); const finalBaseUrl = baseUrl || getBaseUrl(logger); console.error('[DEBUG] FlyerDataTransformer._buildUrls finalBaseUrl resolved to:', finalBaseUrl); const imageUrl = `${finalBaseUrl}/flyer-images/${imageFileName}`; const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`; console.error('[DEBUG] FlyerDataTransformer._buildUrls constructed:', { imageUrl, iconUrl }); logger.debug({ imageUrl, iconUrl }, 'Constructed URLs'); return { imageUrl, iconUrl }; } /** * Transforms AI-extracted data into database-ready flyer and item records. * @param extractedData The validated data from the AI. * @param imagePaths The paths to the flyer images. * @param originalFileName The original name of the uploaded file. * @param checksum The checksum of the file. * @param userId The ID of the user who uploaded the file, if any. * @param logger The request-scoped or job-scoped logger instance. * @returns A promise that resolves to an object containing the prepared flyer and item data. */ async transform( aiResult: AiProcessorResult, originalFileName: string, imageFileName: string, iconFileName: string, checksum: string, userId: string | undefined, logger: Logger, baseUrl: string, ): Promise<{ flyerData: FlyerInsert; itemsForDb: FlyerItemInsert[] }> { console.error('[DEBUG] FlyerDataTransformer.transform called with baseUrl:', baseUrl); logger.info('Starting data transformation from AI output to database format.'); try { const { data: extractedData, needsReview } = aiResult; const { imageUrl, iconUrl } = this._buildUrls(imageFileName, iconFileName, baseUrl, logger); const itemsForDb: FlyerItemInsert[] = extractedData.items.map((item) => this._normalizeItem(item)); const storeName = extractedData.store_name || 'Unknown Store (auto)'; if (!extractedData.store_name) { logger.warn('AI did not return a store name. Using fallback "Unknown Store (auto)".'); } const flyerData: FlyerInsert = { file_name: originalFileName, image_url: imageUrl, icon_url: iconUrl, checksum, store_name: storeName, valid_from: extractedData.valid_from, valid_to: extractedData.valid_to, store_address: extractedData.store_address, item_count: itemsForDb.length, uploaded_by: userId ? userId : null, status: needsReview ? 'needs_review' : 'processed', }; logger.info( { itemCount: itemsForDb.length, storeName: flyerData.store_name }, 'Data transformation complete.', ); return { flyerData, itemsForDb }; } catch (err) { logger.error({ err }, 'Transformation process failed'); // Wrap and rethrow with the new error class throw new TransformationError('Flyer Data Transformation Failed'); } } }