Files
flyer-crawler.projectium.com/src/services/db/flyer.ts
Torben Sorensen 1d0bd630b2
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 48s
test, more id fixes, and naming all files
2025-11-25 05:59:56 -08:00

318 lines
12 KiB
TypeScript

// src/services/db/flyer.ts
import { getPool } from './connection';
import { logger } from '../logger';
import { Flyer, Brand, MasterGroceryItem, FlyerItem } from '../../types';
/**
* Retrieves all flyers from the database, joining with store information.
* @returns A promise that resolves to an array of Flyer objects.
*/
// prettier-ignore
export async function getFlyers(): Promise<Flyer[]> {
try {
const query = `
SELECT
f.flyer_id,
f.created_at,
f.file_name,
f.image_url,
f.checksum,
f.store_id,
f.valid_from,
f.valid_to,
f.store_address,
json_build_object(
'store_id', s.store_id,
'name', s.name,
'logo_url', s.logo_url
) as store
FROM public.flyers f
LEFT JOIN public.stores s ON f.store_id = s.store_id
ORDER BY f.valid_to DESC, s.name ASC;
`;
const res = await getPool().query<Flyer>(query);
return res.rows;
} catch (error) {
logger.error('Database error in getFlyers:', { error });
throw new Error('Failed to retrieve flyers from database.');
}
}
/**
* Retrieves all brands from the database, including the associated store name for store brands.
* @returns A promise that resolves to an array of Brand objects.
*/
// prettier-ignore
export async function getAllBrands(): Promise<Brand[]> {
try {
const query = `
SELECT b.brand_id, b.name, b.logo_url, b.store_id, s.name as store_name
FROM public.brands b
LEFT JOIN public.stores s ON b.store_id = s.store_id
ORDER BY b.name ASC;
`;
const res = await getPool().query<Brand>(query);
return res.rows;
} catch (error) {
logger.error('Database error in getAllBrands:', { error });
throw new Error('Failed to retrieve brands from database.');
}
}
/**
* Retrieves all master grocery items from the database, joining with category information.
* @returns A promise that resolves to an array of MasterGroceryItem objects.
*/
// prettier-ignore
export async function getAllMasterItems(): Promise<MasterGroceryItem[]> {
try {
const query = `
SELECT
m.master_grocery_item_id,
m.created_at,
m.name,
m.category_id,
c.name as category_name
FROM public.master_grocery_items m
LEFT JOIN public.categories c ON m.category_id = c.category_id
ORDER BY m.name ASC;
`;
const res = await getPool().query<MasterGroceryItem>(query);
return res.rows;
} catch (error) {
logger.error('Database error in getAllMasterItems:', { error });
throw new Error('Failed to retrieve master items from database.');
}
}
/**
* Retrieves all categories from the database.
* @returns A promise that resolves to an array of Category objects.
*/
// prettier-ignore
export async function getAllCategories(): Promise<{category_id: number, name: string}[]> {
try {
const query = `
SELECT category_id, name FROM public.categories ORDER BY name ASC;
`;
const res = await getPool().query<{category_id: number, name: string}>(query);
return res.rows;
} catch (error) {
logger.error('Database error in getAllCategories:', { error });
throw new Error('Failed to retrieve categories from database.');
}
}
/**
* Finds a flyer by its checksum to prevent duplicate processing.
* @param checksum The SHA-256 checksum of the flyer file.
* @returns A promise that resolves to the Flyer object if found, otherwise undefined.
*/
// prettier-ignore
export async function findFlyerByChecksum(checksum: string): Promise<Flyer | undefined> {
try {
const res = await getPool().query<Flyer>('SELECT * FROM public.flyers WHERE checksum = $1', [checksum]);
return res.rows[0];
} catch (error) {
logger.error('Database error in findFlyerByChecksum:', { error });
throw new Error('Failed to check for existing flyer.');
}
}
/**
* Creates a new flyer and all its associated items in a single database transaction.
* @param flyerData The metadata for the flyer.
* @param items The array of flyer items extracted from the flyer.
* @returns A promise that resolves to the newly created Flyer object.
*/
// prettier-ignore
export async function createFlyerAndItems(
flyerData: Omit<Flyer, 'flyer_id' | 'created_at' | 'store' | 'store_id'> & { store_name: string },
items: Omit<FlyerItem, 'flyer_item_id' | 'flyer_id' | 'created_at'>[]
): Promise<Flyer> {
const client = await getPool().connect();
logger.debug('[DB createFlyerAndItems] Starting transaction to create flyer.', { flyerData: { name: flyerData.file_name, store_name: flyerData.store_name }, itemCount: items.length });
try {
await client.query('BEGIN');
logger.debug('[DB createFlyerAndItems] BEGIN transaction successful.');
// Find or create the store to get its ID. This logic is now self-contained.
let storeId: number;
const storeRes = await client.query<{ store_id: number }>('SELECT store_id FROM public.stores WHERE name = $1', [flyerData.store_name]);
if (storeRes.rows.length > 0) {
storeId = storeRes.rows[0].store_id;
} else {
const newStoreRes = await client.query<{ store_id: number }>('INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id', [flyerData.store_name]);
storeId = newStoreRes.rows[0].store_id;
}
// Create the flyer record
const flyerQuery = `
INSERT INTO public.flyers (file_name, image_url, checksum, store_id, valid_from, valid_to, store_address, uploaded_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
`;
const flyerValues = [flyerData.file_name, flyerData.image_url, flyerData.checksum, storeId, flyerData.valid_from, flyerData.valid_to, flyerData.store_address, flyerData.uploaded_by];
const newFlyerRes = await client.query<Flyer>(flyerQuery, flyerValues);
const newFlyer = newFlyerRes.rows[0];
// Prepare and insert all flyer items
if (items.length > 0) {
const itemInsertQuery = `
INSERT INTO public.flyer_items (
flyer_id, item, price_display, price_in_cents, quantity,
master_item_id, -- This will be populated by our suggestion function
category_name, unit_price
)
VALUES ($1, $2, $3, $4, $5, public.suggest_master_item_for_flyer_item($2), $6, $7)
`;
// Loop through each item and execute the insert query.
// The query now directly calls the `suggest_master_item_for_flyer_item` function
// on the database side, passing the item name (`item.item`) as the argument.
// This is more efficient than making a separate DB call for each item to get the suggestion.
for (const item of items) {
const itemValues = [
newFlyer.flyer_id,
item.item,
item.price_display,
item.price_in_cents,
item.quantity,
item.category_name,
item.unit_price ? JSON.stringify(item.unit_price) : null // Ensure JSONB is correctly stringified
];
await client.query(itemInsertQuery, itemValues);
}
}
await client.query('COMMIT');
logger.debug(`[DB createFlyerAndItems] COMMIT transaction successful for new flyer ID: ${newFlyer.flyer_id}.`);
return newFlyer;
} catch (error) {
await client.query('ROLLBACK');
logger.error('[DB createFlyerAndItems] ROLLBACK transaction due to error.');
logger.error('Database transaction error in createFlyerAndItems:', { error });
throw new Error('Failed to save flyer and its items.');
} finally {
client.release();
}
}
/**
* Retrieves all items for a specific flyer.
* @param flyerId The ID of the flyer.
* @returns A promise that resolves to an array of FlyerItem objects.
*/
// prettier-ignore
export async function getFlyerItems(flyerId: number): Promise<FlyerItem[]> {
try {
const query = `
SELECT * FROM public.flyer_items
WHERE flyer_id = $1
ORDER BY flyer_item_id ASC;
`;
const res = await getPool().query<FlyerItem>(query, [flyerId]);
return res.rows;
} catch (error) {
logger.error('Database error in getFlyerItems:', { error, flyerId });
throw new Error('Failed to retrieve flyer items.');
}
}
/**
* Retrieves all flyer items for a given list of flyer IDs.
* @param flyerIds An array of flyer IDs.
* @returns A promise that resolves to an array of FlyerItem objects.
*/
// prettier-ignore
export async function getFlyerItemsForFlyers(flyerIds: number[]): Promise<FlyerItem[]> {
try {
const query = `
SELECT * FROM public.flyer_items
WHERE flyer_id = ANY($1::bigint[]);
`;
const res = await getPool().query<FlyerItem>(query, [flyerIds]);
return res.rows;
} catch (error) {
logger.error('Database error in getFlyerItemsForFlyers:', { error });
throw new Error('Failed to retrieve items for multiple flyers.');
}
}
/**
* Counts the total number of flyer items for a given list of flyer IDs.
* @param flyerIds An array of flyer IDs.
* @returns A promise that resolves to the total count of items.
*/
// prettier-ignore
export async function countFlyerItemsForFlyers(flyerIds: number[]): Promise<number> {
try {
const query = `SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::bigint[])`;
const res = await getPool().query<{ count: string }>(query, [flyerIds]);
return parseInt(res.rows[0].count, 10);
} catch (error) {
logger.error('Database error in countFlyerItemsForFlyers:', { error });
throw new Error('Failed to count items for multiple flyers.');
}
}
/**
* Updates the logo URL for a specific store.
* @param storeId The ID of the store to update.
* @param logoUrl The new URL for the store's logo.
*/
// prettier-ignore
export async function updateStoreLogo(storeId: number, logoUrl: string): Promise<void> {
try {
await getPool().query(
'UPDATE public.stores SET logo_url = $1 WHERE id = $2',
[logoUrl, storeId]
);
} catch (error) {
logger.error('Database error in updateStoreLogo:', { error, storeId });
throw new Error('Failed to update store logo in database.');
}
}
/**
* Tracks a user interaction with a flyer item (view or click).
* @param itemId The ID of the flyer item.
* @param type The type of interaction ('view' or 'click').
*/
export async function trackFlyerItemInteraction(itemId: number, type: 'view' | 'click'): Promise<void> {
try {
const column = type === 'view' ? 'view_count' : 'click_count';
// Use the || operator to concatenate the column name safely into the query.
const query = `UPDATE public.flyer_items SET ${column} = ${column} + 1 WHERE flyer_item_id = $1`;
await getPool().query(query, [itemId]);
} catch (error) {
logger.error('Database error in trackFlyerItemInteraction:', { error, itemId, type });
// This is a non-critical operation, so we don't throw an error that would crash the user's request.
}
}
/**
* Retrieves historical price data for a given list of master item IDs.
* This function queries the pre-aggregated `item_price_history` table for efficiency.
* @param masterItemIds An array of master grocery item IDs.
* @returns A promise that resolves to an array of historical price records.
*/
// prettier-ignore
export async function getHistoricalPriceDataForItems(masterItemIds: number[]): Promise<{ master_item_id: number; summary_date: string; avg_price_in_cents: number | null; }[]> {
if (masterItemIds.length === 0) {
return [];
}
try {
const query = `
SELECT master_item_id, summary_date, avg_price_in_cents
FROM public.item_price_history
WHERE master_item_id = ANY($1::bigint[])
ORDER BY summary_date ASC;
`;
const res = await getPool().query(query, [masterItemIds]);
return res.rows;
} catch (error) {
logger.error('Database error in getHistoricalPriceDataForItems:', { error });
throw new Error('Failed to retrieve historical price data.');
}
}