Files
flyer-crawler.projectium.com/src/services/db/flyer.db.ts
Torben Sorensen 2e72ee81dd
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
maybe a few too many fixes
2025-12-28 21:38:31 -08:00

397 lines
14 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 { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
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> {
// Note: This method should be called within a transaction if the caller
// needs to ensure atomicity with other operations.
try {
// First, try to find the store.
let result = await this.db.query<{ store_id: number }>(
'SELECT store_id FROM public.stores WHERE name = $1',
[storeName],
);
if (result.rows.length > 0) {
return result.rows[0].store_id;
} else {
// If not found, create it.
result = await this.db.query<{ store_id: number }>(
'INSERT INTO public.stores (name) VALUES ($1) RETURNING store_id',
[storeName],
);
return result.rows[0].store_id;
}
} catch (error) {
// Check for a unique constraint violation on name, which could happen in a race condition
// if two processes try to create the same store at the same time.
if (error instanceof Error && 'code' in error && error.code === '23505') {
try {
logger.warn(
{ storeName },
`Race condition avoided: Store was created by another process. Refetching.`,
);
const result = await this.db.query<{ store_id: number }>(
'SELECT store_id FROM public.stores WHERE name = $1',
[storeName],
);
if (result.rows.length > 0) return result.rows[0].store_id;
} catch (recoveryError) {
// If recovery fails, log a warning and fall through to the generic error handler
logger.warn({ err: recoveryError, storeName }, 'Race condition recovery failed');
}
}
logger.error({ err: error, storeName }, 'Database error in findOrCreateStore');
throw new Error('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> {
try {
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
flyerData.image_url, // $2
flyerData.icon_url, // $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, // $11
];
const result = await this.db.query<Flyer>(query, values);
return result.rows[0];
} catch (error) {
logger.error({ err: error, flyerData }, 'Database error in insertFlyer');
// Check for a unique constraint violation on the 'checksum' column.
if (error instanceof Error && 'code' in error && error.code === '23505') {
throw new UniqueConstraintError('A flyer with this checksum already exists.');
}
throw new Error('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++})`,
);
values.push(
flyerId,
item.item,
item.price_display,
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 *;
`;
const result = await this.db.query<FlyerItem>(query, values);
return result.rows;
} catch (error) {
logger.error({ err: error, flyerId }, 'Database error in insertFlyerItems');
// Check for a foreign key violation, which would mean the flyerId is invalid.
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified flyer does not exist.');
}
// Preserve the original error if it's not a foreign key violation,
// allowing transactional functions to catch and identify the specific failure.
// This is a higher-level fix for the test failure in `createFlyerAndItems`.
if (error instanceof Error) throw error;
throw new Error('An unknown error occurred while inserting flyer items.');
}
}
/**
* Retrieves all distinct brands from the stores table.
* @returns A promise that resolves to an array of Brand objects.
*/
async getAllBrands(logger: Logger): Promise<Brand[]> {
try {
const query = `
SELECT s.store_id as brand_id, s.name, s.logo_url
FROM public.stores s
ORDER BY s.name;
`;
const res = await this.db.query<Brand>(query);
return res.rows;
} catch (error) {
logger.error({ err: error }, 'Database error in getAllBrands');
throw new Error('Failed to retrieve brands from database.');
}
}
/**
* 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.
* @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[]> {
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) {
logger.error({ err: error, limit, offset }, 'Database error in getFlyers');
throw new Error('Failed to retrieve flyers from database.');
}
}
/**
* 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.
*/
async getFlyerItems(flyerId: number, logger: Logger): Promise<FlyerItem[]> {
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) {
logger.error({ err: error, flyerId }, 'Database error in getFlyerItems');
throw new Error('Failed to retrieve flyer items from database.');
}
}
/**
* 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) {
logger.error({ err: error, flyerIds }, 'Database error in getFlyerItemsForFlyers');
throw new Error('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) {
logger.error({ err: error, flyerIds }, 'Database error in countFlyerItemsForFlyers');
throw new Error('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) {
logger.error({ err: error, checksum }, 'Database error in findFlyerByChecksum');
throw new Error('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.
* @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}`);
});
} catch (error) {
logger.error({ err: error, flyerId }, 'Database transaction error in deleteFlyer');
throw new Error('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,
) {
try {
return await withTransaction(async (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 };
});
} catch (error) {
logger.error({ err: error }, 'Database transaction error in createFlyerAndItems');
throw error; // Re-throw the error to be handled by the calling service.
}
}