Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 48s
318 lines
12 KiB
TypeScript
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.');
|
|
}
|
|
} |