Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
647 lines
24 KiB
TypeScript
647 lines
24 KiB
TypeScript
// src/services/db/admin.db.ts
|
|
import type { Pool, PoolClient } from 'pg';
|
|
import { getPool, withTransaction } from './connection.db';
|
|
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
|
import type { Logger } from 'pino';
|
|
import {
|
|
SuggestedCorrection,
|
|
MostFrequentSaleItem,
|
|
Recipe,
|
|
RecipeComment,
|
|
UnmatchedFlyerItem,
|
|
ActivityLogItem,
|
|
Receipt,
|
|
AdminUserView,
|
|
Profile,
|
|
Flyer,
|
|
} from '../../types';
|
|
|
|
export class AdminRepository {
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Retrieves all pending suggested corrections from the database.
|
|
* Joins with users and flyer_items to provide context for the admin.
|
|
* @returns A promise that resolves to an array of SuggestedCorrection objects.
|
|
*/
|
|
// prettier-ignore
|
|
async getSuggestedCorrections(logger: Logger): Promise<SuggestedCorrection[]> {
|
|
try {
|
|
const query = `
|
|
SELECT
|
|
sc.suggested_correction_id,
|
|
sc.flyer_item_id,
|
|
sc.user_id,
|
|
sc.correction_type,
|
|
sc.suggested_value,
|
|
sc.status,
|
|
sc.created_at,
|
|
fi.item as flyer_item_name,
|
|
fi.price_display as flyer_item_price_display,
|
|
u.email as user_email
|
|
FROM public.suggested_corrections sc
|
|
JOIN public.flyer_items fi ON sc.flyer_item_id = fi.flyer_item_id
|
|
LEFT JOIN public.users u ON sc.user_id = u.user_id
|
|
WHERE sc.status = 'pending'
|
|
ORDER BY sc.created_at ASC;
|
|
`;
|
|
const res = await this.db.query<SuggestedCorrection>(query);
|
|
return res.rows;
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'Database error in getSuggestedCorrections');
|
|
throw new Error('Failed to retrieve suggested corrections.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Approves a correction and applies the change to the corresponding flyer item.
|
|
* This function runs as a transaction to ensure data integrity.
|
|
* @param correctionId The ID of the correction to approve.
|
|
*/
|
|
// prettier-ignore
|
|
async approveCorrection(correctionId: number, logger: Logger): Promise<void> {
|
|
try {
|
|
// The database function `approve_correction` now contains all the logic.
|
|
// It finds the correction, applies the change, and updates the status in a single transaction.
|
|
// This simplifies the application code and keeps the business logic in the database.
|
|
await this.db.query('SELECT public.approve_correction($1)', [correctionId]);
|
|
logger.info(`Successfully approved and applied correction ID: ${correctionId}`);
|
|
} catch (error) {
|
|
logger.error({ err: error, correctionId }, 'Database transaction error in approveCorrection');
|
|
throw new Error('Failed to approve correction.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rejects a correction by updating its status.
|
|
* @param correctionId The ID of the correction to reject.
|
|
*/
|
|
// prettier-ignore
|
|
async rejectCorrection(correctionId: number, logger: Logger): Promise<void> {
|
|
try {
|
|
const res = await this.db.query(
|
|
"UPDATE public.suggested_corrections SET status = 'rejected' WHERE suggested_correction_id = $1 AND status = 'pending' RETURNING suggested_correction_id",
|
|
[correctionId]
|
|
);
|
|
if (res.rowCount === 0) {
|
|
throw new NotFoundError(`Correction with ID ${correctionId} not found or not in 'pending' state.`);
|
|
}
|
|
logger.info(`Successfully rejected correction ID: ${correctionId}`);
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) throw error;
|
|
logger.error({ err: error, correctionId }, 'Database error in rejectCorrection');
|
|
throw new Error('Failed to reject correction.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the suggested value of a pending correction.
|
|
* @param correctionId The ID of the correction to update.
|
|
* @param newSuggestedValue The new value to set for the suggestion.
|
|
* @returns A promise that resolves to the updated SuggestedCorrection object.
|
|
*/
|
|
// prettier-ignore
|
|
async updateSuggestedCorrection(correctionId: number, newSuggestedValue: string, logger: Logger): Promise<SuggestedCorrection> {
|
|
try {
|
|
const res = await this.db.query<SuggestedCorrection>(
|
|
"UPDATE public.suggested_corrections SET suggested_value = $1 WHERE suggested_correction_id = $2 AND status = 'pending' RETURNING *",
|
|
[newSuggestedValue, correctionId]
|
|
);
|
|
if (res.rowCount === 0) {
|
|
throw new NotFoundError(`Correction with ID ${correctionId} not found or is not in 'pending' state.`);
|
|
}
|
|
return res.rows[0];
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) {
|
|
throw error;
|
|
}
|
|
logger.error({ err: error, correctionId }, 'Database error in updateSuggestedCorrection');
|
|
throw new Error('Failed to update suggested correction.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves application-wide statistics for the admin dashboard.
|
|
* @returns A promise that resolves to an object containing various application stats.
|
|
*/
|
|
// prettier-ignore
|
|
async getApplicationStats(logger: Logger): Promise<{
|
|
flyerCount: number;
|
|
userCount: number;
|
|
flyerItemCount: number;
|
|
storeCount: number;
|
|
pendingCorrectionCount: number;
|
|
recipeCount: number;
|
|
}> {
|
|
try {
|
|
// Run count queries in parallel for better performance
|
|
const flyerCountQuery = this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.flyers');
|
|
const userCountQuery = this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.users');
|
|
const flyerItemCountQuery = this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.flyer_items');
|
|
const storeCountQuery = this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.stores');
|
|
const pendingCorrectionCountQuery = this.db.query<{ count: string }>("SELECT COUNT(*) FROM public.suggested_corrections WHERE status = 'pending'");
|
|
const recipeCountQuery = this.db.query<{ count: string }>('SELECT COUNT(*) FROM public.recipes');
|
|
|
|
const [
|
|
flyerCountRes,
|
|
userCountRes,
|
|
flyerItemCountRes,
|
|
storeCountRes,
|
|
pendingCorrectionCountRes,
|
|
recipeCountRes
|
|
] = await Promise.all([
|
|
flyerCountQuery, userCountQuery, flyerItemCountQuery, storeCountQuery, pendingCorrectionCountQuery, recipeCountQuery
|
|
]);
|
|
|
|
return {
|
|
flyerCount: parseInt(flyerCountRes.rows[0].count, 10),
|
|
userCount: parseInt(userCountRes.rows[0].count, 10),
|
|
flyerItemCount: parseInt(flyerItemCountRes.rows[0].count, 10),
|
|
storeCount: parseInt(storeCountRes.rows[0].count, 10),
|
|
pendingCorrectionCount: parseInt(pendingCorrectionCountRes.rows[0].count, 10),
|
|
recipeCount: parseInt(recipeCountRes.rows[0].count, 10),
|
|
};
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'Database error in getApplicationStats');
|
|
throw error; // Re-throw the original error to be handled by the caller
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves daily statistics for user registrations and flyer uploads for the last 30 days.
|
|
* @returns A promise that resolves to an array of daily stats.
|
|
*/
|
|
// prettier-ignore
|
|
async getDailyStatsForLast30Days(logger: Logger): Promise<{ date: string; new_users: number; new_flyers: number; }[]> {
|
|
try {
|
|
const query = `
|
|
WITH date_series AS (
|
|
SELECT generate_series(
|
|
(CURRENT_DATE - interval '29 days'),
|
|
CURRENT_DATE,
|
|
'1 day'::interval
|
|
)::date AS day
|
|
),
|
|
daily_users AS (
|
|
SELECT created_at::date AS day, COUNT(*) AS user_count
|
|
FROM public.users
|
|
WHERE created_at >= (CURRENT_DATE - interval '29 days')
|
|
GROUP BY 1
|
|
),
|
|
daily_flyers AS (
|
|
SELECT created_at::date AS day, COUNT(*) AS flyer_count
|
|
FROM public.flyers
|
|
WHERE created_at >= (CURRENT_DATE - interval '29 days')
|
|
GROUP BY 1
|
|
)
|
|
SELECT
|
|
to_char(ds.day, 'YYYY-MM-DD') as date,
|
|
COALESCE(du.user_count, 0)::int AS new_users,
|
|
COALESCE(df.flyer_count, 0)::int AS new_flyers
|
|
FROM date_series ds
|
|
LEFT JOIN daily_users du ON ds.day = du.day
|
|
LEFT JOIN daily_flyers df ON ds.day = df.day
|
|
ORDER BY ds.day ASC;
|
|
`;
|
|
const res = await this.db.query(query);
|
|
return res.rows;
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'Database error in getDailyStatsForLast30Days');
|
|
throw new Error('Failed to retrieve daily statistics.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calls a database function to get the most frequently advertised items.
|
|
* @param days The number of past days to look back.
|
|
* @param limit The maximum number of items to return.
|
|
* @returns A promise that resolves to an array of the most frequent sale items.
|
|
*/
|
|
async getMostFrequentSaleItems(
|
|
days: number,
|
|
limit: number,
|
|
logger: Logger,
|
|
): Promise<MostFrequentSaleItem[]> {
|
|
// This is a secure parameterized query. The values for `days` and `limit` are passed
|
|
// separately from the query string. The database driver safely substitutes the `$1` and `$2`
|
|
// placeholders, preventing SQL injection attacks.
|
|
// Never use template literals like `WHERE f.valid_from >= NOW() - '${days} days'`
|
|
// as that would be a major security vulnerability.
|
|
try {
|
|
const query = `
|
|
SELECT
|
|
fi.master_item_id,
|
|
mi.name as item_name,
|
|
COUNT(fi.flyer_item_id)::int as sale_count
|
|
FROM public.flyer_items fi
|
|
JOIN public.flyers f ON fi.flyer_id = f.flyer_id
|
|
JOIN public.master_grocery_items mi ON fi.master_item_id = mi.master_grocery_item_id
|
|
WHERE
|
|
f.valid_from >= NOW() - ($1 || ' days')::interval
|
|
AND fi.master_item_id IS NOT NULL
|
|
GROUP BY
|
|
fi.master_item_id, mi.name
|
|
ORDER BY
|
|
sale_count DESC, mi.name ASC
|
|
LIMIT $2;
|
|
`;
|
|
const res = await this.db.query<MostFrequentSaleItem>(query, [days, limit]);
|
|
return res.rows;
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'Database error in getMostFrequentSaleItems');
|
|
throw new Error('Failed to get most frequent sale items.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the status of a recipe comment (e.g., for moderation).
|
|
* @param commentId The ID of the comment to update.
|
|
* @param status The new status ('visible', 'hidden', 'reported').
|
|
* @returns A promise that resolves to the updated RecipeComment object.
|
|
*/
|
|
async updateRecipeCommentStatus(
|
|
commentId: number,
|
|
status: 'visible' | 'hidden' | 'reported',
|
|
logger: Logger,
|
|
): Promise<RecipeComment> {
|
|
try {
|
|
const res = await this.db.query<RecipeComment>(
|
|
'UPDATE public.recipe_comments SET status = $1 WHERE recipe_comment_id = $2 RETURNING *',
|
|
[status, commentId],
|
|
);
|
|
if (res.rowCount === 0) {
|
|
throw new NotFoundError(`Recipe comment with ID ${commentId} not found.`);
|
|
}
|
|
return res.rows[0];
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) {
|
|
throw error;
|
|
}
|
|
logger.error(
|
|
{ err: error, commentId, status },
|
|
'Database error in updateRecipeCommentStatus',
|
|
);
|
|
throw new Error('Failed to update recipe comment status.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves all flyer items that could not be automatically matched to a master item.
|
|
* @returns A promise that resolves to an array of unmatched flyer items with context.
|
|
*/
|
|
async getUnmatchedFlyerItems(logger: Logger): Promise<UnmatchedFlyerItem[]> {
|
|
try {
|
|
const query = `
|
|
SELECT
|
|
ufi.unmatched_flyer_item_id,
|
|
ufi.status,
|
|
ufi.created_at,
|
|
fi.flyer_item_id as flyer_item_id,
|
|
fi.item as flyer_item_name,
|
|
fi.price_display,
|
|
f.flyer_id as flyer_id,
|
|
s.name as store_name
|
|
FROM public.unmatched_flyer_items ufi
|
|
JOIN public.flyer_items fi ON ufi.flyer_item_id = fi.flyer_item_id
|
|
JOIN public.flyers f ON fi.flyer_id = f.flyer_id
|
|
JOIN public.stores s ON f.store_id = s.store_id
|
|
WHERE ufi.status = 'pending'
|
|
ORDER BY ufi.created_at ASC;
|
|
`;
|
|
const res = await this.db.query<UnmatchedFlyerItem>(query);
|
|
return res.rows;
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'Database error in getUnmatchedFlyerItems');
|
|
throw new Error('Failed to retrieve unmatched flyer items.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the status of a recipe (e.g., for moderation).
|
|
* @param recipeId The ID of the recipe to update.
|
|
* @param status The new status ('private', 'pending_review', 'public', 'rejected').
|
|
* @returns A promise that resolves to the updated Recipe object.
|
|
*/
|
|
async updateRecipeStatus(
|
|
recipeId: number,
|
|
status: 'private' | 'pending_review' | 'public' | 'rejected',
|
|
logger: Logger,
|
|
): Promise<Recipe> {
|
|
try {
|
|
const res = await this.db.query<Recipe>(
|
|
'UPDATE public.recipes SET status = $1 WHERE recipe_id = $2 RETURNING *',
|
|
[status, recipeId],
|
|
);
|
|
if (res.rowCount === 0) throw new NotFoundError(`Recipe with ID ${recipeId} not found.`);
|
|
return res.rows[0];
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) {
|
|
throw error;
|
|
}
|
|
logger.error({ err: error, recipeId, status }, 'Database error in updateRecipeStatus');
|
|
throw new Error('Failed to update recipe status.'); // Keep generic for other DB errors
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves an unmatched flyer item by linking it to a master grocery item.
|
|
* This is a transactional operation.
|
|
* @param unmatchedFlyerItemId The ID from the `unmatched_flyer_items` table.
|
|
* @param masterItemId The ID of the `master_grocery_items` to link to.
|
|
*/
|
|
async resolveUnmatchedFlyerItem(
|
|
unmatchedFlyerItemId: number,
|
|
masterItemId: number,
|
|
logger: Logger,
|
|
): Promise<void> {
|
|
try {
|
|
await withTransaction(async (client) => {
|
|
// First, get the flyer_item_id from the unmatched record
|
|
const unmatchedRes = await client.query<{
|
|
flyer_item_id: number;
|
|
}>(
|
|
'SELECT flyer_item_id FROM public.unmatched_flyer_items WHERE unmatched_flyer_item_id = $1 FOR UPDATE',
|
|
[unmatchedFlyerItemId],
|
|
);
|
|
|
|
if (unmatchedRes.rowCount === 0) {
|
|
throw new NotFoundError(
|
|
`Unmatched flyer item with ID ${unmatchedFlyerItemId} not found.`,
|
|
);
|
|
}
|
|
const { flyer_item_id } = unmatchedRes.rows[0];
|
|
|
|
// Next, update the original flyer_items table with the correct master_item_id
|
|
await client.query(
|
|
'UPDATE public.flyer_items SET master_item_id = $1 WHERE flyer_item_id = $2',
|
|
[masterItemId, flyer_item_id],
|
|
);
|
|
|
|
// Finally, update the status of the unmatched record to 'resolved'
|
|
await client.query(
|
|
"UPDATE public.unmatched_flyer_items SET status = 'resolved' WHERE unmatched_flyer_item_id = $1",
|
|
[unmatchedFlyerItemId],
|
|
);
|
|
|
|
logger.info(
|
|
`Successfully resolved unmatched item ${unmatchedFlyerItemId} to master item ${masterItemId}.`,
|
|
);
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) {
|
|
throw error;
|
|
}
|
|
logger.error(
|
|
{ err: error, unmatchedFlyerItemId, masterItemId },
|
|
'Database transaction error in resolveUnmatchedFlyerItem',
|
|
);
|
|
throw new Error('Failed to resolve unmatched flyer item.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ignores an unmatched flyer item by updating its status.
|
|
* @param unmatchedFlyerItemId The ID from the `unmatched_flyer_items` table.
|
|
*/
|
|
async ignoreUnmatchedFlyerItem(unmatchedFlyerItemId: number, logger: Logger): Promise<void> {
|
|
try {
|
|
const res = await this.db.query(
|
|
"UPDATE public.unmatched_flyer_items SET status = 'ignored' WHERE unmatched_flyer_item_id = $1 AND status = 'pending'",
|
|
[unmatchedFlyerItemId],
|
|
);
|
|
if (res.rowCount === 0) {
|
|
throw new NotFoundError(
|
|
`Unmatched flyer item with ID ${unmatchedFlyerItemId} not found or not in 'pending' state.`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) throw error;
|
|
logger.error(
|
|
{ err: error, unmatchedFlyerItemId },
|
|
'Database error in ignoreUnmatchedFlyerItem',
|
|
);
|
|
throw new Error('Failed to ignore unmatched flyer item.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves a paginated list of recent activities from the activity log.
|
|
* @param limit The number of log entries to retrieve.
|
|
* @param offset The number of log entries to skip (for pagination).
|
|
* @returns A promise that resolves to an array of ActivityLogItem objects.
|
|
*/
|
|
// prettier-ignore
|
|
async getActivityLog(limit: number, offset: number, logger: Logger): Promise<ActivityLogItem[]> {
|
|
try {
|
|
const res = await this.db.query<ActivityLogItem>('SELECT * FROM public.get_activity_log($1, $2)', [limit, offset]);
|
|
return res.rows;
|
|
} catch (error) {
|
|
logger.error({ err: error, limit, offset }, 'Database error in getActivityLog');
|
|
throw new Error('Failed to retrieve activity log.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Defines a type for JSON-compatible data structures, allowing for nested objects and arrays.
|
|
* This provides a safer alternative to `any` for objects intended for JSON serialization.
|
|
*/
|
|
async logActivity(
|
|
logData: {
|
|
userId?: string | null;
|
|
action: string;
|
|
displayText: string;
|
|
icon?: string | null;
|
|
details?: Record<string, unknown> | null;
|
|
},
|
|
logger: Logger,
|
|
): Promise<void> {
|
|
const { userId, action, displayText, icon, details } = logData;
|
|
try {
|
|
await this.db.query(
|
|
`INSERT INTO public.activity_log (user_id, action, display_text, icon, details)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[
|
|
userId || null,
|
|
action,
|
|
displayText,
|
|
icon || null,
|
|
details ? JSON.stringify(details) : null,
|
|
],
|
|
);
|
|
} catch (error) {
|
|
logger.error({ err: error, logData }, 'Database error in logActivity');
|
|
// We don't re-throw here to prevent logging failures from crashing critical paths.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Increments the failed login attempt counter for a user.
|
|
* @param userId The ID of the user.
|
|
* @returns A promise that resolves to the new number of failed login attempts, or -1 on error.
|
|
*/
|
|
async incrementFailedLoginAttempts(userId: string, logger: Logger): Promise<number> {
|
|
try {
|
|
const res = await this.db.query<{ failed_login_attempts: number }>(
|
|
`UPDATE public.users
|
|
SET failed_login_attempts = failed_login_attempts + 1, last_failed_login = NOW()
|
|
WHERE user_id = $1
|
|
RETURNING failed_login_attempts`,
|
|
[userId],
|
|
);
|
|
if (res.rowCount === 0) {
|
|
logger.warn(
|
|
{ userId },
|
|
'Attempted to increment failed login attempts for a non-existent user.',
|
|
);
|
|
return 0; // Should not happen if called after user lookup, but safe to handle.
|
|
}
|
|
return res.rows[0].failed_login_attempts;
|
|
} catch (error) {
|
|
logger.error({ err: error, userId }, 'Database error in incrementFailedLoginAttempts');
|
|
return -1; // Return -1 to indicate an error occurred.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resets the failed login attempt counter for a user upon successful login.
|
|
* @param userId The ID of the user.
|
|
* @param ipAddress The IP address from which the successful login occurred.
|
|
*/
|
|
async resetFailedLoginAttempts(userId: string, ipAddress: string, logger: Logger): Promise<void> {
|
|
try {
|
|
await this.db.query(
|
|
`UPDATE public.users
|
|
SET failed_login_attempts = 0, last_failed_login = NULL, last_login_ip = $2, last_login_at = NOW()
|
|
WHERE user_id = $1 AND failed_login_attempts > 0`,
|
|
[userId, ipAddress],
|
|
);
|
|
} catch (error) {
|
|
// This is a non-critical operation, so we just log the error and continue.
|
|
logger.error({ err: error, userId }, 'Database error in resetFailedLoginAttempts');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the logo URL for a specific brand.
|
|
* @param brandId The ID of the brand to update.
|
|
* @param logoUrl The new URL for the brand's logo.
|
|
*/
|
|
// prettier-ignore
|
|
async updateBrandLogo(brandId: number, logoUrl: string, logger: Logger): Promise<void> {
|
|
try {
|
|
const res = await this.db.query(
|
|
'UPDATE public.brands SET logo_url = $1 WHERE brand_id = $2',
|
|
[logoUrl, brandId]
|
|
);
|
|
if (res.rowCount === 0) {
|
|
throw new NotFoundError(`Brand with ID ${brandId} not found.`);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) throw error;
|
|
logger.error({ err: error, brandId }, 'Database error in updateBrandLogo');
|
|
throw new Error('Failed to update brand logo in database.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the status of a specific receipt.
|
|
* @param receiptId The ID of the receipt to update.
|
|
* @param status The new status for the receipt.
|
|
* @returns A promise that resolves to the updated Receipt object.
|
|
*/
|
|
async updateReceiptStatus(
|
|
receiptId: number,
|
|
status: 'pending' | 'processing' | 'completed' | 'failed',
|
|
logger: Logger,
|
|
): Promise<Receipt> {
|
|
try {
|
|
const res = await this.db.query<Receipt>(
|
|
`UPDATE public.receipts SET status = $1, processed_at = CASE WHEN $1 IN ('completed', 'failed') THEN now() ELSE processed_at END WHERE receipt_id = $2 RETURNING *`,
|
|
[status, receiptId],
|
|
);
|
|
if (res.rowCount === 0) throw new NotFoundError(`Receipt with ID ${receiptId} not found.`);
|
|
return res.rows[0];
|
|
} catch (error) {
|
|
if (error instanceof NotFoundError) throw error;
|
|
logger.error({ err: error, receiptId, status }, 'Database error in updateReceiptStatus');
|
|
throw new Error('Failed to update receipt status.');
|
|
}
|
|
}
|
|
|
|
async getAllUsers(logger: Logger): Promise<AdminUserView[]> {
|
|
try {
|
|
const query = `
|
|
SELECT u.user_id, u.email, u.created_at, p.role, p.full_name, p.avatar_url
|
|
FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id ORDER BY u.created_at DESC;
|
|
`;
|
|
const res = await this.db.query<AdminUserView>(query);
|
|
return res.rows;
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'Database error in getAllUsers');
|
|
throw new Error('Failed to retrieve all users.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the role of a specific user.
|
|
* @param userId The ID of the user to update.
|
|
* @param role The new role to assign ('user' or 'admin').
|
|
* @returns A promise that resolves to the updated Profile object.
|
|
*/
|
|
async updateUserRole(userId: string, role: 'user' | 'admin', logger: Logger): Promise<Profile> {
|
|
try {
|
|
const res = await this.db.query<Profile>(
|
|
'UPDATE public.profiles SET role = $1 WHERE user_id = $2 RETURNING *',
|
|
[role, userId],
|
|
);
|
|
if (res.rowCount === 0) {
|
|
throw new NotFoundError(`User with ID ${userId} not found.`);
|
|
}
|
|
return res.rows[0];
|
|
} catch (error) {
|
|
logger.error({ err: error, userId, role }, 'Database error in updateUserRole');
|
|
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
|
throw new ForeignKeyConstraintError('The specified user does not exist.');
|
|
}
|
|
if (error instanceof NotFoundError) {
|
|
throw error;
|
|
}
|
|
throw error; // Re-throw to be handled by the route
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves all flyers that have been flagged with a 'needs_review' status.
|
|
* @param logger The logger instance.
|
|
* @returns A promise that resolves to an array of Flyer objects.
|
|
*/
|
|
async getFlyersForReview(logger: Logger): 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
|
|
LEFT JOIN public.stores s ON f.store_id = s.store_id
|
|
WHERE f.status = 'needs_review'
|
|
ORDER BY f.created_at DESC;
|
|
`;
|
|
const res = await this.db.query<Flyer>(query);
|
|
return res.rows;
|
|
} catch (error) {
|
|
logger.error({ err: error }, 'Database error in getFlyersForReview');
|
|
throw new Error('Failed to retrieve flyers for review.');
|
|
}
|
|
}
|
|
}
|