Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
397 lines
14 KiB
TypeScript
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.
|
|
}
|
|
}
|