// src/services/expiryService.server.ts /** * @file Expiry Date Tracking Service * Handles inventory management, expiry date calculations, and expiry alerts. * Provides functionality for tracking food items and notifying users about expiring items. */ import type { Logger } from 'pino'; import { expiryRepo, receiptRepo } from './db/index.db'; import type { StorageLocation, AlertMethod, UserInventoryItem, AddInventoryItemRequest, UpdateInventoryItemRequest, ExpiryDateRange, AddExpiryRangeRequest, ExpiryAlertSettings, UpdateExpiryAlertSettingsRequest, ExpiringItemsResponse, InventoryQueryOptions, ExpiryRangeQueryOptions, CalculateExpiryOptions, } from '../types/expiry'; /** * Default expiry warning threshold in days */ const DEFAULT_EXPIRY_WARNING_DAYS = 7; /** * Number of days to consider an item "expiring soon" */ const EXPIRING_SOON_THRESHOLD = 7; /** * Number of days to consider for "this month" expiry grouping */ const THIS_MONTH_THRESHOLD = 30; // ============================================================================ // INVENTORY MANAGEMENT // ============================================================================ /** * Adds an item to the user's inventory. * If no expiry date is provided, attempts to calculate one based on storage location. * @param userId The user's ID * @param item The item to add * @param logger Pino logger instance * @returns The created inventory item with computed expiry status */ export const addInventoryItem = async ( userId: string, item: AddInventoryItemRequest, logger: Logger, ): Promise => { const itemLogger = logger.child({ userId, itemName: item.item_name }); itemLogger.info('Adding item to inventory'); // If no expiry date provided and we have purchase date + location, try to calculate if (!item.expiry_date && item.purchase_date && item.location) { const calculatedExpiry = await calculateExpiryDate( { master_item_id: item.master_item_id, item_name: item.item_name, storage_location: item.location, purchase_date: item.purchase_date, }, itemLogger, ); if (calculatedExpiry) { itemLogger.debug({ calculatedExpiry }, 'Calculated expiry date from storage location'); item.expiry_date = calculatedExpiry; } } const inventoryItem = await expiryRepo.addInventoryItem(userId, item, itemLogger); itemLogger.info({ inventoryId: inventoryItem.inventory_id }, 'Item added to inventory'); return inventoryItem; }; /** * Updates an existing inventory item. * @param inventoryId The inventory item ID * @param userId The user's ID (for authorization) * @param updates The updates to apply * @param logger Pino logger instance * @returns The updated inventory item */ export const updateInventoryItem = async ( inventoryId: number, userId: string, updates: UpdateInventoryItemRequest, logger: Logger, ): Promise => { logger.debug({ inventoryId, userId, updates }, 'Updating inventory item'); return expiryRepo.updateInventoryItem(inventoryId, userId, updates, logger); }; /** * Marks an inventory item as consumed. * @param inventoryId The inventory item ID * @param userId The user's ID (for authorization) * @param logger Pino logger instance */ export const markItemConsumed = async ( inventoryId: number, userId: string, logger: Logger, ): Promise => { logger.debug({ inventoryId, userId }, 'Marking item as consumed'); await expiryRepo.markAsConsumed(inventoryId, userId, logger); logger.info({ inventoryId }, 'Item marked as consumed'); }; /** * Deletes an inventory item. * @param inventoryId The inventory item ID * @param userId The user's ID (for authorization) * @param logger Pino logger instance */ export const deleteInventoryItem = async ( inventoryId: number, userId: string, logger: Logger, ): Promise => { logger.debug({ inventoryId, userId }, 'Deleting inventory item'); await expiryRepo.deleteInventoryItem(inventoryId, userId, logger); logger.info({ inventoryId }, 'Item deleted from inventory'); }; /** * Gets a single inventory item by ID. * @param inventoryId The inventory item ID * @param userId The user's ID (for authorization) * @param logger Pino logger instance * @returns The inventory item */ export const getInventoryItemById = async ( inventoryId: number, userId: string, logger: Logger, ): Promise => { return expiryRepo.getInventoryItemById(inventoryId, userId, logger); }; /** * Gets the user's inventory with optional filtering and pagination. * @param options Query options * @param logger Pino logger instance * @returns Paginated inventory items */ export const getInventory = async ( options: InventoryQueryOptions, logger: Logger, ): Promise<{ items: UserInventoryItem[]; total: number }> => { logger.debug({ userId: options.user_id }, 'Fetching user inventory'); return expiryRepo.getInventory(options, logger); }; // ============================================================================ // EXPIRING ITEMS // ============================================================================ /** * Gets items grouped by expiry urgency for dashboard display. * @param userId The user's ID * @param logger Pino logger instance * @returns Items grouped by expiry status with counts */ export const getExpiringItemsGrouped = async ( userId: string, logger: Logger, ): Promise => { logger.debug({ userId }, 'Fetching expiring items grouped by urgency'); // Get all expiring items within 30 days + expired items const expiringThisMonth = await expiryRepo.getExpiringItems(userId, THIS_MONTH_THRESHOLD, logger); const expiredItems = await expiryRepo.getExpiredItems(userId, logger); // Group items by urgency const today = new Date(); today.setHours(0, 0, 0, 0); const expiringToday: UserInventoryItem[] = []; const expiringThisWeek: UserInventoryItem[] = []; const expiringLater: UserInventoryItem[] = []; for (const item of expiringThisMonth) { if (item.days_until_expiry === null) { continue; } if (item.days_until_expiry === 0) { expiringToday.push(item); } else if (item.days_until_expiry <= EXPIRING_SOON_THRESHOLD) { expiringThisWeek.push(item); } else { expiringLater.push(item); } } const response: ExpiringItemsResponse = { expiring_today: expiringToday, expiring_this_week: expiringThisWeek, expiring_this_month: expiringLater, already_expired: expiredItems, counts: { today: expiringToday.length, this_week: expiringThisWeek.length, this_month: expiringLater.length, expired: expiredItems.length, total: expiringToday.length + expiringThisWeek.length + expiringLater.length + expiredItems.length, }, }; logger.info( { userId, counts: response.counts, }, 'Expiring items fetched', ); return response; }; /** * Gets items expiring within a specified number of days. * @param userId The user's ID * @param daysAhead Number of days to look ahead * @param logger Pino logger instance * @returns Items expiring within the specified timeframe */ export const getExpiringItems = async ( userId: string, daysAhead: number, logger: Logger, ): Promise => { logger.debug({ userId, daysAhead }, 'Fetching expiring items'); return expiryRepo.getExpiringItems(userId, daysAhead, logger); }; /** * Gets items that have already expired. * @param userId The user's ID * @param logger Pino logger instance * @returns Expired items */ export const getExpiredItems = async ( userId: string, logger: Logger, ): Promise => { logger.debug({ userId }, 'Fetching expired items'); return expiryRepo.getExpiredItems(userId, logger); }; // ============================================================================ // EXPIRY DATE CALCULATION // ============================================================================ /** * Calculates an estimated expiry date based on item and storage location. * Uses expiry_date_ranges table for reference data. * @param options Calculation options * @param logger Pino logger instance * @returns Calculated expiry date string (ISO format) or null if unable to calculate */ export const calculateExpiryDate = async ( options: CalculateExpiryOptions, logger: Logger, ): Promise => { const { master_item_id, category_id, item_name, storage_location, purchase_date } = options; logger.debug( { masterItemId: master_item_id, categoryId: category_id, itemName: item_name, storageLocation: storage_location, }, 'Calculating expiry date', ); // Look up expiry range for this item/category/pattern const expiryRange = await expiryRepo.getExpiryRangeForItem(storage_location, logger, { masterItemId: master_item_id, categoryId: category_id, itemName: item_name, }); if (!expiryRange) { logger.debug('No expiry range found for item'); return null; } // Calculate expiry date using typical_days const purchaseDateTime = new Date(purchase_date); purchaseDateTime.setDate(purchaseDateTime.getDate() + expiryRange.typical_days); const expiryDateStr = purchaseDateTime.toISOString().split('T')[0]; logger.debug( { purchaseDate: purchase_date, typicalDays: expiryRange.typical_days, expiryDate: expiryDateStr, }, 'Expiry date calculated', ); return expiryDateStr; }; /** * Gets expiry date ranges with optional filtering. * @param options Query options * @param logger Pino logger instance * @returns Paginated expiry date ranges */ export const getExpiryRanges = async ( options: ExpiryRangeQueryOptions, logger: Logger, ): Promise<{ ranges: ExpiryDateRange[]; total: number }> => { return expiryRepo.getExpiryRanges(options, logger); }; /** * Adds a new expiry date range (admin operation). * @param range The range to add * @param logger Pino logger instance * @returns The created expiry range */ export const addExpiryRange = async ( range: AddExpiryRangeRequest, logger: Logger, ): Promise => { logger.info( { storageLocation: range.storage_location, typicalDays: range.typical_days }, 'Adding expiry range', ); return expiryRepo.addExpiryRange(range, logger); }; // ============================================================================ // EXPIRY ALERTS // ============================================================================ /** * Gets the user's expiry alert settings. * @param userId The user's ID * @param logger Pino logger instance * @returns Array of alert settings */ export const getAlertSettings = async ( userId: string, logger: Logger, ): Promise => { return expiryRepo.getUserAlertSettings(userId, logger); }; /** * Updates the user's expiry alert settings for a specific alert method. * @param userId The user's ID * @param alertMethod The alert delivery method * @param settings The settings to update * @param logger Pino logger instance * @returns Updated alert settings */ export const updateAlertSettings = async ( userId: string, alertMethod: AlertMethod, settings: UpdateExpiryAlertSettingsRequest, logger: Logger, ): Promise => { logger.debug({ userId, alertMethod, settings }, 'Updating alert settings'); return expiryRepo.upsertAlertSettings(userId, alertMethod, settings, logger); }; /** * Processes expiry alerts for all users. * This should be called by a scheduled worker job. * @param logger Pino logger instance * @returns Number of alerts sent */ export const processExpiryAlerts = async (logger: Logger): Promise => { logger.info('Starting expiry alert processing'); // Get all users with expiring items who have alerts enabled const usersToNotify = await expiryRepo.getUsersWithExpiringItems(logger); logger.debug({ userCount: usersToNotify.length }, 'Found users to notify'); let alertsSent = 0; for (const user of usersToNotify) { try { // Get the expiring items for this user const expiringItems = await expiryRepo.getExpiringItems( user.user_id, user.days_before_expiry, logger, ); if (expiringItems.length === 0) { continue; } // Send notification based on alert method switch (user.alert_method) { case 'email': await sendExpiryEmailAlert(user.user_id, user.email, expiringItems, logger); break; case 'push': // TODO: Implement push notifications logger.debug({ userId: user.user_id }, 'Push notifications not yet implemented'); break; case 'in_app': // TODO: Implement in-app notifications logger.debug({ userId: user.user_id }, 'In-app notifications not yet implemented'); break; } // Log the alert and mark as sent for (const item of expiringItems) { await expiryRepo.logAlert( user.user_id, 'expiring_soon', user.alert_method, item.item_name, logger, { pantryItemId: item.inventory_id, expiryDate: item.expiry_date, daysUntilExpiry: item.days_until_expiry, }, ); } await expiryRepo.markAlertSent(user.user_id, user.alert_method, logger); alertsSent++; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); logger.error({ err, userId: user.user_id }, 'Error processing expiry alert for user'); } } logger.info({ alertsSent }, 'Expiry alert processing completed'); return alertsSent; }; /** * Sends an email alert about expiring items. * @param userId The user's ID * @param email The user's email * @param items The expiring items * @param logger Pino logger instance */ const sendExpiryEmailAlert = async ( userId: string, email: string, items: UserInventoryItem[], logger: Logger, ): Promise => { const alertLogger = logger.child({ userId, email, itemCount: items.length }); alertLogger.info('Sending expiry alert email'); // Group items by urgency const expiredItems = items.filter((i) => i.days_until_expiry !== null && i.days_until_expiry < 0); const todayItems = items.filter((i) => i.days_until_expiry === 0); const soonItems = items.filter( (i) => i.days_until_expiry !== null && i.days_until_expiry > 0 && i.days_until_expiry <= 3, ); const laterItems = items.filter((i) => i.days_until_expiry !== null && i.days_until_expiry > 3); // Build the email content const subject = todayItems.length > 0 || expiredItems.length > 0 ? '⚠️ Food Items Expiring Today or Already Expired!' : `🕐 ${items.length} Food Item${items.length > 1 ? 's' : ''} Expiring Soon`; const buildItemList = (itemList: UserInventoryItem[], emoji: string): string => { if (itemList.length === 0) return ''; return itemList .map((item) => { const daysText = item.days_until_expiry === 0 ? 'today' : item.days_until_expiry === 1 ? 'tomorrow' : item.days_until_expiry !== null && item.days_until_expiry < 0 ? `${Math.abs(item.days_until_expiry)} day${Math.abs(item.days_until_expiry) > 1 ? 's' : ''} ago` : `in ${item.days_until_expiry} days`; const location = item.location ? ` (${item.location})` : ''; return `${emoji} ${item.item_name}${location} - expires ${daysText}`; }) .join('
'); }; let htmlBody = ''; if (expiredItems.length > 0) { htmlBody += `

Already Expired (${expiredItems.length})

${buildItemList(expiredItems, '❌')}

`; } if (todayItems.length > 0) { htmlBody += `

Expiring Today (${todayItems.length})

${buildItemList(todayItems, '⚠️')}

`; } if (soonItems.length > 0) { htmlBody += `

Expiring Within 3 Days (${soonItems.length})

${buildItemList(soonItems, '🕐')}

`; } if (laterItems.length > 0) { htmlBody += `

Expiring This Week (${laterItems.length})

${buildItemList(laterItems, '📅')}

`; } const html = `

Food Expiry Alert

The following items in your pantry need attention:

${htmlBody}

Visit your inventory page to manage these items. You can also find recipe suggestions to use them before they expire!

To manage your alert preferences, visit your settings page.

`; // Build plain text version const buildTextList = (itemList: UserInventoryItem[]): string => { return itemList .map((item) => { const daysText = item.days_until_expiry === 0 ? 'today' : item.days_until_expiry === 1 ? 'tomorrow' : item.days_until_expiry !== null && item.days_until_expiry < 0 ? `${Math.abs(item.days_until_expiry)} day(s) ago` : `in ${item.days_until_expiry} days`; return ` - ${item.item_name} - expires ${daysText}`; }) .join('\n'); }; let textBody = 'Food Expiry Alert\n\nThe following items need attention:\n\n'; if (expiredItems.length > 0) { textBody += `Already Expired:\n${buildTextList(expiredItems)}\n\n`; } if (todayItems.length > 0) { textBody += `Expiring Today:\n${buildTextList(todayItems)}\n\n`; } if (soonItems.length > 0) { textBody += `Expiring Within 3 Days:\n${buildTextList(soonItems)}\n\n`; } if (laterItems.length > 0) { textBody += `Expiring This Week:\n${buildTextList(laterItems)}\n\n`; } textBody += 'Visit your inventory page to manage these items.\n\nFlyer Crawler'; try { await emailService.sendEmail( { to: email, subject, text: textBody, html, }, alertLogger, ); alertLogger.info('Expiry alert email sent successfully'); } catch (error) { alertLogger.error({ err: error }, 'Failed to send expiry alert email'); throw error; } }; // ============================================================================ // RECEIPT INTEGRATION // ============================================================================ /** * Adds items from a confirmed receipt to the user's inventory. * @param userId The user's ID * @param receiptId The receipt ID * @param itemConfirmations Array of item confirmations with storage locations * @param logger Pino logger instance * @returns Array of created inventory items */ export const addItemsFromReceipt = async ( userId: string, receiptId: number, itemConfirmations: Array<{ receipt_item_id: number; item_name?: string; quantity?: number; location?: StorageLocation; expiry_date?: string; include: boolean; }>, logger: Logger, ): Promise => { const receiptLogger = logger.child({ userId, receiptId }); receiptLogger.info( { itemCount: itemConfirmations.length }, 'Adding items from receipt to inventory', ); const createdItems: UserInventoryItem[] = []; // Get receipt details for purchase date const receipt = await receiptRepo.getReceiptById(receiptId, userId, receiptLogger); for (const confirmation of itemConfirmations) { if (!confirmation.include) { receiptLogger.debug( { receiptItemId: confirmation.receipt_item_id }, 'Skipping excluded item', ); continue; } try { // Get the receipt item details const receiptItems = await receiptRepo.getReceiptItems(receiptId, receiptLogger); const receiptItem = receiptItems.find( (ri) => ri.receipt_item_id === confirmation.receipt_item_id, ); if (!receiptItem) { receiptLogger.warn( { receiptItemId: confirmation.receipt_item_id }, 'Receipt item not found', ); continue; } // Create inventory item const inventoryItem = await addInventoryItem( userId, { product_id: receiptItem.product_id ?? undefined, master_item_id: receiptItem.master_item_id ?? undefined, item_name: confirmation.item_name || receiptItem.raw_item_description, quantity: confirmation.quantity || receiptItem.quantity, purchase_date: receipt.transaction_date || receipt.created_at.split('T')[0], expiry_date: confirmation.expiry_date, source: 'receipt_scan', location: confirmation.location, }, receiptLogger, ); // Update receipt item to mark as added to pantry await receiptRepo.updateReceiptItem( confirmation.receipt_item_id, { added_to_pantry: true, pantry_item_id: inventoryItem.inventory_id, }, receiptLogger, ); createdItems.push(inventoryItem); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); receiptLogger.error( { err, receiptItemId: confirmation.receipt_item_id }, 'Error adding receipt item to inventory', ); } } receiptLogger.info({ createdCount: createdItems.length }, 'Items added from receipt'); return createdItems; }; /** * Gets recipe suggestions based on expiring items. * Prioritizes recipes that use items closest to expiry. * @param userId The user's ID * @param daysAhead Number of days to look ahead for expiring items * @param logger Pino logger instance * @param options Pagination options * @returns Recipes with matching expiring ingredients */ export const getRecipeSuggestionsForExpiringItems = async ( userId: string, daysAhead: number, logger: Logger, options: { limit?: number; offset?: number } = {}, ): Promise<{ recipes: Array<{ recipe_id: number; recipe_name: string; description: string | null; prep_time_minutes: number | null; cook_time_minutes: number | null; servings: number | null; photo_url: string | null; matching_items: UserInventoryItem[]; match_count: number; }>; total: number; considered_items: UserInventoryItem[]; }> => { const { limit = 10, offset = 0 } = options; const suggestionLogger = logger.child({ userId, daysAhead }); suggestionLogger.debug('Fetching recipe suggestions for expiring items'); // Get expiring items to include in the response const expiringItems = await getExpiringItems(userId, daysAhead, logger); if (expiringItems.length === 0) { suggestionLogger.debug('No expiring items found, returning empty suggestions'); return { recipes: [], total: 0, considered_items: [], }; } // Get recipes that use the expiring items const recipeData = await expiryRepo.getRecipesForExpiringItems( userId, daysAhead, limit, offset, suggestionLogger, ); // Map the expiring items by master_item_id for quick lookup const itemsByMasterId = new Map(); for (const item of expiringItems) { if (item.master_item_id && !itemsByMasterId.has(item.master_item_id)) { itemsByMasterId.set(item.master_item_id, item); } } // Build the response with matching items const recipes = recipeData.recipes.map((recipe) => ({ recipe_id: recipe.recipe_id, recipe_name: recipe.recipe_name, description: recipe.description, prep_time_minutes: recipe.prep_time_minutes, cook_time_minutes: recipe.cook_time_minutes, servings: recipe.servings, photo_url: recipe.photo_url, matching_items: recipe.matching_master_item_ids .map((id) => itemsByMasterId.get(id)) .filter((item): item is UserInventoryItem => item !== undefined), match_count: recipe.match_count, })); suggestionLogger.info( { recipeCount: recipes.length, total: recipeData.total, expiringItemCount: expiringItems.length, }, 'Recipe suggestions fetched for expiring items', ); return { recipes, total: recipeData.total, considered_items: expiringItems, }; }; // ============================================================================ // JOB PROCESSING // ============================================================================ import type { Job } from 'bullmq'; import type { ExpiryAlertJobData } from '../types/job-data'; import * as emailService from './emailService.server'; /** * Processes an expiry alert job from the queue. * This is the main entry point for background expiry alert processing. * @param job The BullMQ job * @param logger Pino logger instance * @returns Processing result with counts of alerts sent */ export const processExpiryAlertJob = async ( job: Job, logger: Logger, ): Promise<{ success: boolean; alertsSent: number; usersNotified: number }> => { const { alertType, userId, daysAhead = DEFAULT_EXPIRY_WARNING_DAYS, scheduledAt: _scheduledAt, } = job.data; const jobLogger = logger.child({ jobId: job.id, alertType, userId, daysAhead, requestId: job.data.meta?.requestId, }); jobLogger.info('Starting expiry alert job'); try { let alertsSent = 0; let usersNotified = 0; if (alertType === 'user_specific' && userId) { // Process alerts for a single user const result = await processUserExpiryAlerts(userId, daysAhead, jobLogger); alertsSent = result.alertsSent; usersNotified = result.alertsSent > 0 ? 1 : 0; } else if (alertType === 'daily_check') { // Process daily alerts for all users with expiring items const result = await processDailyExpiryAlerts(daysAhead, jobLogger); alertsSent = result.totalAlerts; usersNotified = result.usersNotified; } jobLogger.info({ alertsSent, usersNotified }, 'Expiry alert job completed'); return { success: true, alertsSent, usersNotified }; } catch (error) { jobLogger.error({ err: error }, 'Expiry alert job failed'); throw error; } }; /** * Processes expiry alerts for a single user. * @param userId The user's ID * @param daysAhead Days ahead to check for expiring items * @param logger Pino logger instance * @returns Number of alerts sent */ const processUserExpiryAlerts = async ( userId: string, daysAhead: number, logger: Logger, ): Promise<{ alertsSent: number }> => { const userLogger = logger.child({ userId }); // Get user's alert settings const settings = await expiryRepo.getUserAlertSettings(userId, userLogger); const enabledSettings = settings.filter((s) => s.is_enabled); if (enabledSettings.length === 0) { userLogger.debug('No enabled alert settings for user'); return { alertsSent: 0 }; } // Get expiring items const expiringItems = await getExpiringItems(userId, daysAhead, userLogger); if (expiringItems.length === 0) { userLogger.debug('No expiring items for user'); return { alertsSent: 0 }; } let alertsSent = 0; // Group items by urgency for the alert (kept for future use in alert formatting) const _expiredItems = expiringItems.filter((i) => i.expiry_status === 'expired'); const _soonItems = expiringItems.filter((i) => i.expiry_status === 'expiring_soon'); // Check if we should send alerts based on settings for (const setting of enabledSettings) { const relevantItems = expiringItems.filter( (item) => item.days_until_expiry !== null && item.days_until_expiry <= setting.days_before_expiry, ); if (relevantItems.length > 0) { // Log the alert for (const item of relevantItems) { const alertType: ExpiryAlertType = item.expiry_status === 'expired' ? 'expired' : 'expiring_soon'; await expiryRepo.logAlert( userId, alertType, setting.alert_method, item.item_name, userLogger, { pantryItemId: item.inventory_id, expiryDate: item.expiry_date || null, daysUntilExpiry: item.days_until_expiry, }, ); alertsSent++; } // Update last alert sent time via upsert await expiryRepo.upsertAlertSettings(userId, setting.alert_method, {}, userLogger); } } userLogger.info({ alertsSent, itemCount: expiringItems.length }, 'Processed user expiry alerts'); return { alertsSent }; }; /** * Processes daily expiry alerts for all users. * @param daysAhead Days ahead to check for expiring items * @param logger Pino logger instance * @returns Total alerts and users notified */ const processDailyExpiryAlerts = async ( daysAhead: number, logger: Logger, ): Promise<{ totalAlerts: number; usersNotified: number }> => { // Get all users with items expiring within the threshold const usersWithExpiringItems = await expiryRepo.getUsersWithExpiringItems(logger); // Get unique user IDs const uniqueUserIds = [...new Set(usersWithExpiringItems.map((u) => u.user_id))]; let totalAlerts = 0; let usersNotified = 0; for (const userId of uniqueUserIds) { try { const result = await processUserExpiryAlerts(userId, daysAhead, logger); totalAlerts += result.alertsSent; if (result.alertsSent > 0) { usersNotified++; } } catch (error) { logger.error({ err: error, userId }, 'Failed to process alerts for user'); // Continue with other users } } logger.info( { totalAlerts, usersNotified, totalUsers: uniqueUserIds.length }, 'Daily expiry alert processing complete', ); return { totalAlerts, usersNotified }; };