Files
flyer-crawler.projectium.com/src/services/db/shopping.db.ts
Torben Sorensen e39a7560ee
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 5m57s
home page errors in progress
2025-12-09 23:11:09 -08:00

464 lines
20 KiB
TypeScript

// src/services/db/shopping.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { ForeignKeyConstraintError, UniqueConstraintError } from './errors.db';
import { logger } from '../logger.server';
import {
ShoppingList,
ShoppingListItem,
MenuPlanShoppingListItem,
PantryLocation,
ShoppingTrip,
Receipt,
ReceiptItem,
ReceiptDeal,
} from '../../types';
export class ShoppingRepository {
private db: Pool | PoolClient;
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
/**
* Retrieves all shopping lists and their items for a user.
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of ShoppingList objects.
*/
async getShoppingLists(userId: string): Promise<ShoppingList[]> {
try {
const query = `
SELECT
sl.shopping_list_id, sl.name, sl.created_at,
COALESCE(json_agg(
json_build_object(
'shopping_list_item_id', sli.shopping_list_item_id,
'shopping_list_id', sli.shopping_list_id,
'master_item_id', sli.master_item_id,
'custom_item_name', sli.custom_item_name,
'quantity', sli.quantity,
'is_purchased', sli.is_purchased,
'added_at', sli.added_at,
'master_item', json_build_object('name', mgi.name)
)
) FILTER (WHERE sli.shopping_list_item_id IS NOT NULL), '[]'::json) as items
FROM public.shopping_lists sl
LEFT JOIN public.shopping_list_items sli ON sl.shopping_list_id = sli.shopping_list_id
LEFT JOIN public.master_grocery_items mgi ON sli.master_item_id = mgi.master_grocery_item_id
WHERE sl.user_id = $1
GROUP BY sl.shopping_list_id
ORDER BY sl.created_at ASC;
`;
const res = await this.db.query<ShoppingList>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getShoppingLists:', { error, userId });
throw new Error('Failed to retrieve shopping lists.');
}
}
/**
* Creates a new shopping list for a user.
* @param userId The ID of the user creating the list.
* @param name The name of the new shopping list.
* @returns A promise that resolves to the newly created ShoppingList object.
*/
async createShoppingList(userId: string, name: string): Promise<ShoppingList> {
try {
const res = await this.db.query<ShoppingList>(
'INSERT INTO public.shopping_lists (user_id, name) VALUES ($1, $2) RETURNING shopping_list_id, user_id, name, created_at',
[userId, name]
);
return { ...res.rows[0], items: [] };
} catch (error) {
// The patch requested this specific error handling.
if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error('Database error in createShoppingList:', { error });
// The patch requested this specific error handling.
throw new Error('Failed to create shopping list.');
}
}
/**
* Retrieves a single shopping list by its ID, ensuring user ownership.
* @param listId The ID of the shopping list to retrieve.
* @param userId The ID of the user requesting the list.
* @returns A promise that resolves to the ShoppingList object or undefined if not found or not owned by the user.
*/
async getShoppingListById(listId: number, userId: string): Promise<ShoppingList | undefined> {
try {
const query = `
SELECT
sl.shopping_list_id, sl.name, sl.created_at,
COALESCE(json_agg(
json_build_object(
'shopping_list_item_id', sli.shopping_list_item_id,
'shopping_list_id', sli.shopping_list_id,
'master_item_id', sli.master_item_id,
'custom_item_name', sli.custom_item_name,
'quantity', sli.quantity,
'is_purchased', sli.is_purchased,
'added_at', sli.added_at,
'master_item', json_build_object('name', mgi.name)
)
) FILTER (WHERE sli.shopping_list_item_id IS NOT NULL), '[]'::json) as items
FROM public.shopping_lists sl
LEFT JOIN public.shopping_list_items sli ON sl.shopping_list_id = sli.shopping_list_id
LEFT JOIN public.master_grocery_items mgi ON sli.master_item_id = mgi.master_grocery_item_id
WHERE sl.shopping_list_id = $1 AND sl.user_id = $2
GROUP BY sl.shopping_list_id;
`;
const res = await this.db.query<ShoppingList>(query, [listId, userId]);
return res.rows[0];
} catch (error) {
logger.error('Database error in getShoppingListById:', { error, listId, userId });
throw new Error('Failed to retrieve shopping list.');
}
}
/**
* Deletes a shopping list owned by a specific user.
* @param listId The ID of the shopping list to delete.
* @param userId The ID of the user who owns the list, for an ownership check.
*/
async deleteShoppingList(listId: number, userId: string): Promise<void> {
try {
const res = await this.db.query('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [listId, userId]);
// The patch requested this specific error handling.
if (res.rowCount === 0) {
throw new Error('Shopping list not found or user does not have permission to delete.');
}
} catch (error) {
// The patch requested this specific error handling.
logger.error('Database error in deleteShoppingList:', { error, listId, userId });
throw new Error('Failed to delete shopping list.');
}
}
/**
* Adds a new item to a shopping list.
* @param listId The ID of the shopping list to add the item to.
* @param item An object containing either a `masterItemId` or a `customItemName`.
* @returns A promise that resolves to the newly created ShoppingListItem object.
*/
async addShoppingListItem(listId: number, item: { masterItemId?: number, customItemName?: string }): Promise<ShoppingListItem> {
// The patch requested this specific error handling.
if (!item.masterItemId && !item.customItemName) {
throw new Error('Either masterItemId or customItemName must be provided.');
}
try {
const res = await this.db.query<ShoppingListItem>(
'INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name) VALUES ($1, $2, $3) RETURNING *',
[listId, item.masterItemId ?? null, item.customItemName ?? null]
);
return res.rows[0];
} catch (error) {
// The patch requested this specific error handling.
if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('Referenced list or item does not exist.');
}
logger.error('Database error in addShoppingListItem:', { error });
throw new Error('Failed to add item to shopping list.');
}
}
/**
* Removes an item from a shopping list.
* @param itemId The ID of the shopping list item to remove.
*/
async removeShoppingListItem(itemId: number): Promise<void> {
try {
const res = await this.db.query('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [itemId]);
// The patch requested this specific error handling.
if (res.rowCount === 0) {
throw new Error('Shopping list item not found.');
}
} catch (error) {
// The patch requested this specific error handling.
if (error instanceof Error && error.message.startsWith('Shopping list item not found')) throw error;
logger.error('Database error in removeShoppingListItem:', { error, itemId });
throw new Error('Failed to remove item from shopping list.');
}
}
/**
* Calls a database function to generate a shopping list from a menu plan.
* @param menuPlanId The ID of the menu plan.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of items for the shopping list.
*/
async generateShoppingListForMenuPlan(menuPlanId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
try {
const res = await this.db.query<MenuPlanShoppingListItem>('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [menuPlanId, userId]);
return res.rows;
} catch (error) {
logger.error('Database error in generateShoppingListForMenuPlan:', { error, menuPlanId });
throw new Error('Failed to generate shopping list for menu plan.');
}
}
/**
* Calls a database function to add items from a menu plan to a shopping list.
* @param menuPlanId The ID of the menu plan.
* @param shoppingListId The ID of the shopping list to add items to.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of the items that were added.
*/
async addMenuPlanToShoppingList(menuPlanId: number, shoppingListId: number, userId: string): Promise<MenuPlanShoppingListItem[]> {
try {
const res = await this.db.query<MenuPlanShoppingListItem>('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [menuPlanId, shoppingListId, userId]);
return res.rows;
} catch (error) {
logger.error('Database error in addMenuPlanToShoppingList:', { error, menuPlanId });
throw new Error('Failed to add menu plan to shopping list.');
}
}
/**
* Retrieves all pantry locations defined by a user.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of PantryLocation objects.
*/
async getPantryLocations(userId: string): Promise<PantryLocation[]> {
try {
const res = await this.db.query<PantryLocation>('SELECT * FROM public.pantry_locations WHERE user_id = $1 ORDER BY name', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getPantryLocations:', { error, userId });
throw new Error('Failed to get pantry locations.');
}
}
/**
* Creates a new pantry location for a user.
* @param userId The ID of the user.
* @param name The name of the new location (e.g., "Fridge").
* @returns A promise that resolves to the newly created PantryLocation object.
*/
async createPantryLocation(userId: string, name: string): Promise<PantryLocation> {
try {
const res = await this.db.query<PantryLocation>(
'INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, $2) RETURNING *',
[userId, name]
);
return res.rows[0];
} catch (error) {
// The patch requested this specific error handling.
if ((error as any).code === '23505') {
throw new UniqueConstraintError('Location name exists');
} else if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('User not found');
}
logger.error('Database error in createPantryLocation:', { error });
throw new Error('Failed to create pantry location.');
}
}
/**
* Updates an existing item in a shopping list.
* @param itemId The ID of the shopping list item to update.
* @param updates A partial object of the fields to update (e.g., quantity, is_purchased).
* @returns A promise that resolves to the updated ShoppingListItem object.
*/
async updateShoppingListItem(itemId: number, updates: Partial<ShoppingListItem>): Promise<ShoppingListItem> {
try {
if (Object.keys(updates).length === 0) {
throw new Error('No valid fields to update.');
}
const setClauses = [];
const values = [];
let valueIndex = 1;
if ('quantity' in updates) {
setClauses.push(`quantity = $${valueIndex++}`);
values.push(updates.quantity);
}
if ('is_purchased' in updates) {
setClauses.push(`is_purchased = $${valueIndex++}`);
values.push(updates.is_purchased);
}
if ('notes' in updates) {
setClauses.push(`notes = $${valueIndex++}`);
values.push(updates.notes);
}
if (setClauses.length === 0) {
throw new Error("No valid fields to update.");
}
values.push(itemId);
const query = `UPDATE public.shopping_list_items SET ${setClauses.join(', ')} WHERE shopping_list_item_id = $${valueIndex} RETURNING *`;
const res = await this.db.query<ShoppingListItem>(query, values);
// The patch requested this specific error handling.
if (res.rowCount === 0) {
throw new Error('Shopping list item not found.');
}
return res.rows[0];
} catch (error) {
// Re-throw specific, known errors to allow for more precise error handling in the calling code.
if (error instanceof Error && (error.message.startsWith('Shopping list item not found') || error.message.startsWith('No valid fields'))) {
throw error;
}
logger.error('Database error in updateShoppingListItem:', { error, itemId, updates });
throw new Error('Failed to update shopping list item.');
}
}
/**
* Archives a shopping list into a historical shopping trip.
* @param shoppingListId The ID of the shopping list to complete.
* @param userId The ID of the user owning the list.
* @param totalSpentCents Optional total amount spent on the trip.
* @returns A promise that resolves to the ID of the newly created shopping trip.
*/
async completeShoppingList(shoppingListId: number, userId: string, totalSpentCents?: number): Promise<number> {
try {
const res = await this.db.query<{ complete_shopping_list: number }>(
'SELECT public.complete_shopping_list($1, $2, $3)',
[shoppingListId, userId, totalSpentCents]
);
return res.rows[0].complete_shopping_list;
} catch (error) {
// The patch requested this specific error handling.
if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('The specified shopping list does not exist.');
}
logger.error('Database error in completeShoppingList:', { error });
throw new Error('Failed to complete shopping list.');
}
}
/**
* Retrieves the historical shopping trips for a user, including all purchased items.
* @param userId The ID of the user.
* @returns A promise that resolves to an array of ShoppingTrip objects.
*/
async getShoppingTripHistory(userId: string): Promise<ShoppingTrip[]> {
try {
const query = `
SELECT
st.shopping_trip_id, st.user_id, st.shopping_list_id, st.completed_at, st.total_spent_cents,
COALESCE(
json_agg(
json_build_object(
'shopping_trip_item_id', sti.shopping_trip_item_id,
'master_item_id', sti.master_item_id,
'custom_item_name', sti.custom_item_name,
'quantity', sti.quantity,
'price_paid_cents', sti.price_paid_cents,
'master_item_name', mgi.name
) ORDER BY mgi.name ASC, sti.custom_item_name ASC
) FILTER (WHERE sti.shopping_trip_item_id IS NOT NULL),
'[]'::json
) as items
FROM public.shopping_trips st
LEFT JOIN public.shopping_trip_items sti ON st.shopping_trip_id = sti.shopping_trip_id
LEFT JOIN public.master_grocery_items mgi ON sti.master_item_id = mgi.master_grocery_item_id
WHERE st.user_id = $1
GROUP BY st.shopping_trip_id
ORDER BY st.completed_at DESC;
`;
const res = await this.db.query<ShoppingTrip>(query, [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getShoppingTripHistory:', { error, userId });
throw new Error('Failed to retrieve shopping trip history.');
}
}
/**
* Creates a new receipt record in the database.
* @param userId The ID of the user uploading the receipt.
* @param receiptImageUrl The URL where the receipt image is stored.
* @returns A promise that resolves to the newly created Receipt object.
*/
async createReceipt(userId: string, receiptImageUrl: string): Promise<Receipt> {
try {
const res = await this.db.query<Receipt>(
`INSERT INTO public.receipts (user_id, receipt_image_url, status)
VALUES ($1, $2, 'pending')
RETURNING *`,
[userId, receiptImageUrl]
);
return res.rows[0];
} catch (error) {
// The patch requested this specific error handling.
if ((error as any).code === '23503') {
throw new ForeignKeyConstraintError('User not found');
}
logger.error('Database error in createReceipt:', { error, userId });
throw new Error('Failed to create receipt record.');
}
}
/**
* Processes extracted receipt items, updates the receipt status, and saves the items.
* @param receiptId The ID of the receipt being processed.
* @param items An array of items extracted from the receipt.
* @returns A promise that resolves when the operation is complete.
*/
async processReceiptItems(
receiptId: number,
items: Omit<ReceiptItem, 'receipt_item_id' | 'receipt_id' | 'status' | 'master_item_id' | 'product_id' | 'quantity'>[]
): Promise<void> {
const client = await (this.db as Pool).connect();
try {
await client.query('BEGIN');
const itemsWithQuantity = items.map(item => ({ ...item, quantity: 1 }));
// Use the transactional client for this operation
await client.query('SELECT public.process_receipt_items($1, $2, $3)', [receiptId, JSON.stringify(itemsWithQuantity), JSON.stringify(itemsWithQuantity)]);
logger.info(`Successfully processed items for receipt ID: ${receiptId}`);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
logger.error('Database transaction error in processReceiptItems:', { error, receiptId });
// After rolling back, update the receipt status in a separate, non-transactional query.
// This ensures the failure status is saved even if the transaction failed.
try {
await this.db.query("UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1", [receiptId]);
} catch (updateError) {
logger.error('Failed to update receipt status to "failed" after transaction rollback.', { updateError, receiptId });
}
throw new Error('Failed to process and save receipt items.');
} finally {
client.release();
}
}
/**
* Finds better deals for items on a recently processed receipt.
* @param receiptId The ID of the receipt to check.
* @returns A promise that resolves to an array of potential deals.
*/
async findDealsForReceipt(receiptId: number): Promise<ReceiptDeal[]> {
try {
const res = await this.db.query<ReceiptDeal>('SELECT * FROM public.find_deals_for_receipt_items($1)', [receiptId]);
return res.rows;
} catch (error) {
logger.error('Database error in findDealsForReceipt:', { error, receiptId });
throw new Error('Failed to find deals for receipt.');
}
}
/**
* Finds the owner of a specific receipt.
* @param receiptId The ID of the receipt.
* @returns A promise that resolves to an object containing the user_id, or undefined if not found.
*/
async findReceiptOwner(receiptId: number): Promise<{ user_id: string } | undefined> {
try {
const res = await this.db.query<{ user_id: string }>(
'SELECT user_id FROM public.receipts WHERE receipt_id = $1',
[receiptId]
);
return res.rows[0];
} catch (error) {
logger.error('Database error in findReceiptOwner:', { error, receiptId });
throw new Error('Failed to retrieve receipt owner from database.');
}
}
}