581 lines
22 KiB
TypeScript
581 lines
22 KiB
TypeScript
// src/services/db/shopping.db.ts
|
|
import type { Pool, PoolClient } from 'pg';
|
|
import { getPool, withTransaction } from './connection.db';
|
|
import { NotFoundError, handleDbError } from './errors.db';
|
|
import type { Logger } from 'pino';
|
|
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, logger: Logger): Promise<ShoppingList[]> {
|
|
try {
|
|
const query = `
|
|
SELECT sl.shopping_list_id, sl.name, sl.created_at, sl.updated_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,
|
|
'updated_at', sli.updated_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) {
|
|
handleDbError(error, logger, 'Database error in getShoppingLists', { userId }, {
|
|
defaultMessage: '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, logger: Logger): 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, updated_at',
|
|
[userId, name],
|
|
);
|
|
return { ...res.rows[0], items: [] };
|
|
} catch (error) {
|
|
handleDbError(error, logger, 'Database error in createShoppingList', { userId, name }, {
|
|
fkMessage: 'The specified user does not exist.',
|
|
defaultMessage: '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, logger: Logger): Promise<ShoppingList> {
|
|
try {
|
|
const query = `
|
|
SELECT sl.shopping_list_id, sl.name, sl.created_at, sl.updated_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,
|
|
'updated_at', sli.updated_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]);
|
|
if (res.rowCount === 0) {
|
|
throw new NotFoundError(
|
|
'Shopping list not found or you do not have permission to view it.',
|
|
);
|
|
}
|
|
return res.rows[0];
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) throw error;
|
|
handleDbError(error, logger, 'Database error in getShoppingListById', { listId, userId }, {
|
|
defaultMessage: '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, logger: Logger): 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 NotFoundError(
|
|
'Shopping list not found or user does not have permission to delete.',
|
|
);
|
|
}
|
|
} catch (error) {
|
|
handleDbError(error, logger, 'Database error in deleteShoppingList', { listId, userId }, {
|
|
defaultMessage: '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,
|
|
userId: string,
|
|
item: { masterItemId?: number; customItemName?: string },
|
|
logger: Logger,
|
|
): 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 query = `
|
|
INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, custom_item_name)
|
|
SELECT $1, $2, $3
|
|
WHERE EXISTS (
|
|
SELECT 1 FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $4
|
|
)
|
|
RETURNING *;
|
|
`;
|
|
const res = await this.db.query<ShoppingListItem>(query, [
|
|
listId,
|
|
item.masterItemId ?? null,
|
|
item.customItemName ?? null,
|
|
userId,
|
|
]);
|
|
|
|
if (res.rowCount === 0) {
|
|
throw new NotFoundError('Shopping list not found or user does not have permission.');
|
|
}
|
|
|
|
return res.rows[0];
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) throw error;
|
|
handleDbError(error, logger, 'Database error in addShoppingListItem', { listId, userId, item }, {
|
|
fkMessage: 'Referenced list or item does not exist.',
|
|
checkMessage: 'Shopping list item must have a master item or a custom name.',
|
|
defaultMessage: '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, userId: string, logger: Logger): Promise<void> {
|
|
try {
|
|
const query = `
|
|
DELETE FROM public.shopping_list_items sli
|
|
WHERE sli.shopping_list_item_id = $1
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.shopping_lists sl
|
|
WHERE sl.shopping_list_id = sli.shopping_list_id AND sl.user_id = $2
|
|
);
|
|
`;
|
|
const res = await this.db.query(query, [itemId, userId]);
|
|
if (res.rowCount === 0) {
|
|
throw new NotFoundError('Shopping list item not found or user does not have permission.');
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) throw error;
|
|
handleDbError(error, logger, 'Database error in removeShoppingListItem', { itemId, userId }, {
|
|
defaultMessage: '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,
|
|
logger: Logger,
|
|
): 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) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in generateShoppingListForMenuPlan',
|
|
{ menuPlanId, userId },
|
|
{ defaultMessage: '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,
|
|
logger: Logger,
|
|
): 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) {
|
|
handleDbError(
|
|
error,
|
|
logger,
|
|
'Database error in addMenuPlanToShoppingList',
|
|
{ menuPlanId, shoppingListId, userId },
|
|
{ fkMessage: 'The specified menu plan, shopping list, or an item within the plan does not exist.', defaultMessage: '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, logger: Logger): 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) {
|
|
handleDbError(error, logger, 'Database error in getPantryLocations', { userId }, {
|
|
defaultMessage: '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,
|
|
logger: Logger,
|
|
): 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) {
|
|
handleDbError(error, logger, 'Database error in createPantryLocation', { userId, name }, {
|
|
uniqueMessage: 'A pantry location with this name already exists.',
|
|
fkMessage: 'User not found',
|
|
notNullMessage: 'Pantry location name cannot be null.',
|
|
defaultMessage: '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,
|
|
userId: string,
|
|
updates: Partial<ShoppingListItem>,
|
|
logger: Logger,
|
|
): 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);
|
|
values.push(userId);
|
|
|
|
const query = `
|
|
UPDATE public.shopping_list_items sli
|
|
SET ${setClauses.join(', ')}
|
|
FROM public.shopping_lists sl
|
|
WHERE sli.shopping_list_item_id = $${valueIndex}
|
|
AND sli.shopping_list_id = sl.shopping_list_id
|
|
AND sl.user_id = $${valueIndex + 1}
|
|
RETURNING sli.*;
|
|
`;
|
|
|
|
const res = await this.db.query<ShoppingListItem>(query, values);
|
|
if (res.rowCount === 0) {
|
|
throw new NotFoundError('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 NotFoundError ||
|
|
(error instanceof Error && error.message.startsWith('No valid fields'))
|
|
) {
|
|
throw error;
|
|
}
|
|
handleDbError(error, logger, 'Database error in updateShoppingListItem', { itemId, userId, updates }, {
|
|
defaultMessage: '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,
|
|
logger: Logger,
|
|
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) {
|
|
handleDbError(error, logger, 'Database error in completeShoppingList', { shoppingListId, userId }, {
|
|
fkMessage: 'The specified shopping list does not exist.',
|
|
defaultMessage: '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, logger: Logger): Promise<ShoppingTrip[]> {
|
|
try {
|
|
const query = `
|
|
SELECT
|
|
st.shopping_trip_id, st.user_id, st.shopping_list_id, st.completed_at, st.total_spent_cents, st.updated_at,
|
|
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,
|
|
'created_at', sti.created_at,
|
|
'updated_at', sti.updated_at,
|
|
'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) {
|
|
handleDbError(error, logger, 'Database error in getShoppingTripHistory', { userId }, {
|
|
defaultMessage: '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, logger: Logger): 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) {
|
|
handleDbError(error, logger, 'Database error in createReceipt', { userId, receiptImageUrl }, {
|
|
fkMessage: 'User not found',
|
|
defaultMessage: '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'
|
|
| 'created_at'
|
|
| 'updated_at'
|
|
>[],
|
|
logger: Logger,
|
|
): Promise<void> {
|
|
try {
|
|
await withTransaction(async (client) => {
|
|
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}`);
|
|
});
|
|
} catch (error) {
|
|
// After the transaction fails and is rolled back by withTransaction,
|
|
// update the receipt status in a separate, non-transactional query.
|
|
try {
|
|
await this.db.query("UPDATE public.receipts SET status = 'failed' WHERE receipt_id = $1", [
|
|
receiptId,
|
|
]);
|
|
} catch (updateError) {
|
|
logger.error(
|
|
{ updateError, receiptId },
|
|
'Failed to update receipt status to "failed" after transaction rollback.',
|
|
);
|
|
}
|
|
handleDbError(error, logger, 'Database transaction error in processReceiptItems', { receiptId }, {
|
|
fkMessage: 'The specified receipt or an item within it does not exist.',
|
|
defaultMessage: 'Failed to process and save receipt items.',
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, logger: Logger): 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) {
|
|
handleDbError(error, logger, 'Database error in findDealsForReceipt', { receiptId }, {
|
|
defaultMessage: '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,
|
|
logger: Logger,
|
|
): 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) {
|
|
handleDbError(error, logger, 'Database error in findReceiptOwner', { receiptId }, {
|
|
defaultMessage: 'Failed to retrieve receipt owner from database.',
|
|
});
|
|
}
|
|
}
|
|
}
|