Files
flyer-crawler.projectium.com/src/services/expiryService.server.ts
Torben Sorensen 11aeac5edd
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
whoa - so much - new features (UPC,etc) - Sentry for app logging! so much more !
2026-01-11 19:07:02 -08:00

956 lines
30 KiB
TypeScript

// 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<UserInventoryItem> => {
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<UserInventoryItem> => {
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<void> => {
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<void> => {
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<UserInventoryItem> => {
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<ExpiringItemsResponse> => {
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<UserInventoryItem[]> => {
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<UserInventoryItem[]> => {
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<string | null> => {
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<ExpiryDateRange> => {
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<ExpiryAlertSettings[]> => {
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<ExpiryAlertSettings> => {
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<number> => {
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<void> => {
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} <strong>${item.item_name}</strong>${location} - expires ${daysText}`;
})
.join('<br>');
};
let htmlBody = '';
if (expiredItems.length > 0) {
htmlBody += `<h3 style="color: #dc3545;">Already Expired (${expiredItems.length})</h3>
<p>${buildItemList(expiredItems, '❌')}</p>`;
}
if (todayItems.length > 0) {
htmlBody += `<h3 style="color: #fd7e14;">Expiring Today (${todayItems.length})</h3>
<p>${buildItemList(todayItems, '⚠️')}</p>`;
}
if (soonItems.length > 0) {
htmlBody += `<h3 style="color: #ffc107;">Expiring Within 3 Days (${soonItems.length})</h3>
<p>${buildItemList(soonItems, '🕐')}</p>`;
}
if (laterItems.length > 0) {
htmlBody += `<h3 style="color: #28a745;">Expiring This Week (${laterItems.length})</h3>
<p>${buildItemList(laterItems, '📅')}</p>`;
}
const html = `
<div style="font-family: sans-serif; padding: 20px; max-width: 600px;">
<h2 style="color: #333;">Food Expiry Alert</h2>
<p>The following items in your pantry need attention:</p>
${htmlBody}
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<p style="color: #666; font-size: 14px;">
Visit your <a href="${process.env.FRONTEND_URL || 'https://flyer-crawler.projectium.com'}/inventory">inventory page</a>
to manage these items. You can also find
<a href="${process.env.FRONTEND_URL || 'https://flyer-crawler.projectium.com'}/recipes/suggestions">recipe suggestions</a>
to use them before they expire!
</p>
<p style="color: #999; font-size: 12px;">
To manage your alert preferences, visit your <a href="${process.env.FRONTEND_URL || 'https://flyer-crawler.projectium.com'}/settings">settings page</a>.
</p>
</div>
`;
// 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<UserInventoryItem[]> => {
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<number, UserInventoryItem>();
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<ExpiryAlertJobData>,
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 };
};