Files
flyer-crawler.projectium.com/src/services/db/flyer.db.ts
Torben Sorensen 6ab473f5f0
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 58s
huge linting fixes
2026-01-09 18:50:04 -08:00

550 lines
18 KiB
TypeScript

// src/services/db/flyer.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db';
import type { Logger } from 'pino';
import { NotFoundError, handleDbError } from './errors.db';
import { cacheService, CACHE_TTL, CACHE_PREFIX } from '../cacheService.server';
import type {
Flyer,
FlyerItem,
FlyerInsert,
FlyerItemInsert,
Brand,
FlyerDbInsert,
} from '../../types';
export class FlyerRepository {
// The repository only needs an object with a `query` method, matching the Pool/PoolClient interface.
// Using `Pick` makes this dependency explicit and simplifies testing by reducing the mock surface.
private db: Pick<Pool | PoolClient, 'query'>;
constructor(db: Pick<Pool | PoolClient, 'query'> = getPool()) {
this.db = db;
}
/**
* Finds a store by name, or creates it if it doesn't exist.
* This method is designed to be safe for concurrent calls.
* @param storeName The name of the store.
* @returns A promise that resolves to the store's ID.
*/
async findOrCreateStore(storeName: string, logger: Logger): Promise<number> {
try {
// Atomically insert the store if it doesn't exist. This is safe from race conditions.
await this.db.query(
'INSERT INTO public.stores (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
[storeName],
);
// Now, the store is guaranteed to exist, so we can safely select its ID.
const result = await this.db.query<{ store_id: number }>(
'SELECT store_id FROM public.stores WHERE name = $1',
[storeName],
);
// This case should be virtually impossible if the INSERT...ON CONFLICT logic is correct,
// as it would mean the store was deleted between the two queries. We throw an error to be safe.
if (result.rows.length === 0) {
throw new Error('Failed to find store immediately after upsert operation.');
}
return result.rows[0].store_id;
} catch (error) {
// Use the centralized error handler for any unexpected database errors.
handleDbError(
error,
logger,
'Database error in findOrCreateStore',
{ storeName },
{
// Any error caught here is unexpected, so we use a generic message.
defaultMessage: 'Failed to find or create store in database.',
},
);
}
}
/**
* Inserts a new flyer into the database.
* @param flyerData - The data for the new flyer.
* @returns The newly created flyer record with its ID.
*/
async insertFlyer(flyerData: FlyerDbInsert, logger: Logger): Promise<Flyer> {
console.error(
'[DB DEBUG] FlyerRepository.insertFlyer called with:',
JSON.stringify(flyerData, null, 2),
);
// Sanitize icon_url: Ensure empty strings become NULL to avoid regex constraint violations
let iconUrl =
flyerData.icon_url && flyerData.icon_url.trim() !== '' ? flyerData.icon_url : null;
let imageUrl = flyerData.image_url || 'placeholder.jpg';
try {
// Fallback for tests/workers sending relative URLs to satisfy DB 'url_check' constraint
const rawBaseUrl = process.env.FRONTEND_URL || 'https://example.com';
const baseUrl = rawBaseUrl.endsWith('/') ? rawBaseUrl.slice(0, -1) : rawBaseUrl;
// [DEBUG] Log URL transformation for debugging test failures
if ((imageUrl && !imageUrl.startsWith('http')) || (iconUrl && !iconUrl.startsWith('http'))) {
console.error('[DB DEBUG] Transforming relative URLs:', {
baseUrl,
originalImage: imageUrl,
originalIcon: iconUrl,
});
}
if (imageUrl && !imageUrl.startsWith('http')) {
const cleanPath = imageUrl.startsWith('/') ? imageUrl.substring(1) : imageUrl;
imageUrl = `${baseUrl}/${cleanPath}`;
}
if (iconUrl && !iconUrl.startsWith('http')) {
const cleanPath = iconUrl.startsWith('/') ? iconUrl.substring(1) : iconUrl;
iconUrl = `${baseUrl}/${cleanPath}`;
}
console.error('[DB DEBUG] Final URLs for insert:', { imageUrl, iconUrl });
const query = `
INSERT INTO flyers (
file_name, image_url, icon_url, checksum, store_id, valid_from, valid_to, store_address,
status, item_count, uploaded_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *;
`;
const values = [
flyerData.file_name, // $1
imageUrl, // $2
iconUrl, // $3
flyerData.checksum, // $4
flyerData.store_id, // $5
flyerData.valid_from, // $6
flyerData.valid_to, // $7
flyerData.store_address, // $8
flyerData.status, // $9
flyerData.item_count, // $10
flyerData.uploaded_by ?? null, // $11
];
logger.debug(
{ query, values },
'[DB insertFlyer] Executing insert with the following values.',
);
const result = await this.db.query<Flyer>(query, values);
return result.rows[0];
} catch (error) {
console.error('[DB DEBUG] insertFlyer caught error:', error);
const errorMessage = error instanceof Error ? error.message : '';
let checkMsg = 'A database check constraint failed.';
// [ENHANCED LOGGING]
if (errorMessage.includes('url_check')) {
logger.error(
{
error: errorMessage,
offendingData: {
image_url: flyerData.image_url,
icon_url: flyerData.icon_url, // Log raw input
sanitized_icon_url:
flyerData.icon_url && flyerData.icon_url.trim() !== '' ? flyerData.icon_url : null,
},
},
'[DB ERROR] URL Check Constraint Failed. Inspecting URLs.',
);
}
if (errorMessage.includes('flyers_checksum_check')) {
checkMsg =
'The provided checksum is invalid or does not meet format requirements (e.g., must be a 64-character SHA-256 hash).';
} else if (errorMessage.includes('flyers_status_check')) {
checkMsg = 'Invalid status provided for flyer.';
} else if (errorMessage.includes('url_check')) {
checkMsg = `[URL_CHECK_FAIL] Invalid URL format. Image: '${imageUrl}', Icon: '${iconUrl}'`;
}
handleDbError(
error,
logger,
'Database error in insertFlyer',
{ flyerData },
{
uniqueMessage: 'A flyer with this checksum already exists.',
fkMessage: 'The specified user or store for this flyer does not exist.',
checkMessage: checkMsg,
defaultMessage: 'Failed to insert flyer into database.',
},
);
}
}
/**
* Inserts multiple flyer items into the database for a given flyer ID.
* @param flyerId - The ID of the parent flyer.
* @param items - An array of item data to insert.
* @returns An array of the newly created flyer item records.
*/
async insertFlyerItems(
flyerId: number,
items: FlyerItemInsert[],
logger: Logger,
): Promise<FlyerItem[]> {
try {
if (!items || items.length === 0) {
return [];
}
const values: (string | number | null)[] = [];
const valueStrings: string[] = [];
let paramIndex = 1;
for (const item of items) {
valueStrings.push(
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`,
);
// Sanitize price_display. The database requires a non-empty string.
// We provide a default value if the input is null, undefined, or an empty string.
const priceDisplay =
item.price_display && item.price_display.trim() !== '' ? item.price_display : 'N/A';
values.push(
flyerId,
item.item,
priceDisplay,
item.price_in_cents ?? null,
item.quantity ?? '',
item.category_name ?? null,
item.view_count,
item.click_count,
);
}
const query = `
INSERT INTO flyer_items (
flyer_id, item, price_display, price_in_cents, quantity, category_name, view_count, click_count
)
VALUES ${valueStrings.join(', ')}
RETURNING *;
`;
logger.debug(
{ query, values },
'[DB insertFlyerItems] Executing bulk insert with the following values.',
);
const result = await this.db.query<FlyerItem>(query, values);
return result.rows;
} catch (error) {
handleDbError(
error,
logger,
'Database error in insertFlyerItems',
{ flyerId },
{
fkMessage: 'The specified flyer, category, master item, or product does not exist.',
defaultMessage: 'An unknown error occurred while inserting flyer items.',
},
);
}
}
/**
* Retrieves all distinct brands from the stores table.
* Uses cache-aside pattern with 1-hour TTL (brands rarely change).
* @returns A promise that resolves to an array of Brand objects.
*/
async getAllBrands(logger: Logger): Promise<Brand[]> {
const cacheKey = CACHE_PREFIX.BRANDS;
return cacheService.getOrSet<Brand[]>(
cacheKey,
async () => {
try {
const query = `
SELECT s.store_id as brand_id, s.name, s.logo_url, s.created_at, s.updated_at
FROM public.stores s
ORDER BY s.name;
`;
const res = await this.db.query<Brand>(query);
return res.rows;
} catch (error) {
handleDbError(
error,
logger,
'Database error in getAllBrands',
{},
{
defaultMessage: 'Failed to retrieve brands from database.',
},
);
}
},
{ ttl: CACHE_TTL.BRANDS, logger },
);
}
/**
* Retrieves a single flyer by its ID.
* @param flyerId The ID of the flyer to retrieve.
* @returns A promise that resolves to the Flyer object or undefined if not found.
*/
async getFlyerById(flyerId: number): Promise<Flyer> {
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE flyer_id = $1', [
flyerId,
]);
if (res.rowCount === 0) throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
return res.rows[0];
}
/**
* Retrieves all flyers from the database, ordered by creation date.
* Uses cache-aside pattern with 5-minute TTL.
* @param limit The maximum number of flyers to return.
* @param offset The number of flyers to skip.
* @returns A promise that resolves to an array of Flyer objects.
*/
async getFlyers(logger: Logger, limit: number = 20, offset: number = 0): Promise<Flyer[]> {
const cacheKey = `${CACHE_PREFIX.FLYERS}:${limit}:${offset}`;
return cacheService.getOrSet<Flyer[]>(
cacheKey,
async () => {
try {
const query = `
SELECT
f.*,
json_build_object(
'store_id', s.store_id,
'name', s.name,
'logo_url', s.logo_url
) as store
FROM public.flyers f
JOIN public.stores s ON f.store_id = s.store_id
ORDER BY f.created_at DESC LIMIT $1 OFFSET $2`;
const res = await this.db.query<Flyer>(query, [limit, offset]);
return res.rows;
} catch (error) {
handleDbError(
error,
logger,
'Database error in getFlyers',
{ limit, offset },
{
defaultMessage: 'Failed to retrieve flyers from database.',
},
);
}
},
{ ttl: CACHE_TTL.FLYERS, logger },
);
}
/**
* Retrieves all items for a specific flyer.
* Uses cache-aside pattern with 10-minute TTL.
* @param flyerId The ID of the flyer.
* @returns A promise that resolves to an array of FlyerItem objects.
*/
async getFlyerItems(flyerId: number, logger: Logger): Promise<FlyerItem[]> {
const cacheKey = `${CACHE_PREFIX.FLYER_ITEMS}:${flyerId}`;
return cacheService.getOrSet<FlyerItem[]>(
cacheKey,
async () => {
try {
const res = await this.db.query<FlyerItem>(
'SELECT * FROM public.flyer_items WHERE flyer_id = $1 ORDER BY flyer_item_id ASC',
[flyerId],
);
return res.rows;
} catch (error) {
handleDbError(
error,
logger,
'Database error in getFlyerItems',
{ flyerId },
{
defaultMessage: 'Failed to retrieve flyer items from database.',
},
);
}
},
{ ttl: CACHE_TTL.FLYER_ITEMS, logger },
);
}
/**
* 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 all matching FlyerItem objects.
*/
async getFlyerItemsForFlyers(flyerIds: number[], logger: Logger): Promise<FlyerItem[]> {
try {
const res = await this.db.query<FlyerItem>(
'SELECT * FROM public.flyer_items WHERE flyer_id = ANY($1::int[]) ORDER BY flyer_id, flyer_item_id ASC',
[flyerIds],
);
return res.rows;
} catch (error) {
handleDbError(
error,
logger,
'Database error in getFlyerItemsForFlyers',
{ flyerIds },
{
defaultMessage: 'Failed to retrieve flyer items in batch from database.',
},
);
}
}
/**
* 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.
*/
async countFlyerItemsForFlyers(flyerIds: number[], logger: Logger): Promise<number> {
try {
if (flyerIds.length === 0) {
return 0;
}
const res = await this.db.query<{ count: string }>(
'SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::int[])',
[flyerIds],
);
return parseInt(res.rows[0].count, 10);
} catch (error) {
handleDbError(
error,
logger,
'Database error in countFlyerItemsForFlyers',
{ flyerIds },
{
defaultMessage: 'Failed to count flyer items in batch from database.',
},
);
}
}
/**
* Finds a single flyer by its SHA-256 checksum.
* @param checksum The checksum of the flyer file to find.
* @returns A promise that resolves to the Flyer object or undefined if not found.
*/
async findFlyerByChecksum(checksum: string, logger: Logger): Promise<Flyer | undefined> {
try {
const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE checksum = $1', [
checksum,
]);
return res.rows[0];
} catch (error) {
handleDbError(
error,
logger,
'Database error in findFlyerByChecksum',
{ checksum },
{
defaultMessage: 'Failed to find flyer by checksum in database.',
},
);
}
}
/**
* Tracks a user interaction (view or click) for a specific flyer item.
* This is a "fire-and-forget" operation from the client's perspective, so errors are logged but not re-thrown.
* @param flyerItemId The ID of the flyer item.
* @param interactionType The type of interaction, either 'view' or 'click'.
*/
async trackFlyerItemInteraction(
flyerItemId: number,
interactionType: 'view' | 'click',
logger: Logger,
): Promise<void> {
try {
// Choose the column to increment based on the interaction type.
// This is safe from SQL injection as the input is strictly controlled to be 'view' or 'click'.
const columnToIncrement = interactionType === 'view' ? 'view_count' : 'click_count';
const query = `
UPDATE public.flyer_items
SET ${columnToIncrement} = ${columnToIncrement} + 1
WHERE flyer_item_id = $1;
`;
await this.db.query(query, [flyerItemId]);
} catch (error) {
logger.error(
{ err: error, flyerItemId, interactionType },
'Database error in trackFlyerItemInteraction (non-critical)',
);
}
}
/**
* Deletes a flyer and all its associated items in a transaction.
* This should typically be an admin-only action.
* Invalidates related cache entries after successful deletion.
* @param flyerId The ID of the flyer to delete.
*/
async deleteFlyer(flyerId: number, logger: Logger): Promise<void> {
try {
await withTransaction(async (client) => {
// The schema is set up with ON DELETE CASCADE for flyer_items,
// so we only need to delete from the parent 'flyers' table.
// The database will handle deleting associated flyer_items, unmatched_flyer_items, etc.
const res = await client.query('DELETE FROM public.flyers WHERE flyer_id = $1', [flyerId]);
if (res.rowCount === 0) {
throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
}
logger.info(`Successfully deleted flyer with ID: ${flyerId}`);
});
// Invalidate cache after successful deletion
await cacheService.invalidateFlyer(flyerId, logger);
} catch (error) {
handleDbError(
error,
logger,
'Database transaction error in deleteFlyer',
{ flyerId },
{
defaultMessage: 'Failed to delete flyer.',
},
);
}
}
}
/**
* Creates a new flyer and its associated items within a single database transaction.
*
* @param flyerData - The data for the flyer.
* @param itemsForDb - An array of item data to associate with the flyer.
* @returns An object containing the new flyer and its items.
*/
export async function createFlyerAndItems(
flyerData: FlyerInsert,
itemsForDb: FlyerItemInsert[],
logger: Logger,
client: PoolClient,
) {
// The calling service is now responsible for managing the transaction.
// This function assumes it is being run within a transaction via the provided client.
const flyerRepo = new FlyerRepository(client);
// 1. Find or create the store to get the store_id
const storeId = await flyerRepo.findOrCreateStore(flyerData.store_name, logger);
// 2. Prepare the data for the flyer table, replacing store_name with store_id
const flyerDbData: FlyerDbInsert = { ...flyerData, store_id: storeId };
// 3. Insert the flyer record
const newFlyer = await flyerRepo.insertFlyer(flyerDbData, logger);
// 4. Insert the associated flyer items
const newItems = await flyerRepo.insertFlyerItems(newFlyer.flyer_id, itemsForDb, logger);
return { flyer: newFlyer, items: newItems };
}