Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
956 lines
30 KiB
TypeScript
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 };
|
|
};
|