unit test fixes + error refactor
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m52s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m52s
This commit is contained in:
@@ -15,8 +15,8 @@ vi.mock('./hooks/useAuth', () => ({
|
||||
|
||||
// Mock top-level components rendered by App's routes
|
||||
vi.mock('./components/Header', () => ({ Header: (props: any) => <header data-testid="header-mock"><button onClick={props.onOpenProfile}>Open Profile</button><button onClick={props.onOpenVoiceAssistant}>Open Voice Assistant</button></header> }));
|
||||
vi.mock('./pages/admin/components/ProfileManager', () => ({ ProfileManager: ({ onClose, onProfileUpdate, onLoginSuccess }: { onClose: () => void, onProfileUpdate: (p: any) => void, onLoginSuccess: (u: any, t: string) => void }) => <div data-testid="profile-manager-mock"><button onClick={onClose}>Close Profile</button><button onClick={() => onProfileUpdate({ full_name: 'Updated' })}>Update Profile</button><button onClick={() => onLoginSuccess({}, 'token')}>Login</button></div> }));
|
||||
vi.mock('./features/voice-assistant/VoiceAssistant', () => ({ VoiceAssistant: ({ onClose }: { onClose: () => void }) => <div data-testid="voice-assistant-mock"><button onClick={onClose}>Close Voice Assistant</button></div> }));
|
||||
vi.mock('./pages/admin/components/ProfileManager', () => ({ ProfileManager: ({ isOpen, onClose, onProfileUpdate, onLoginSuccess }: { isOpen: boolean, onClose: () => void, onProfileUpdate: (p: any) => void, onLoginSuccess: (u: any, t: string) => void }) => isOpen ? <div data-testid="profile-manager-mock"><button onClick={onClose}>Close Profile</button><button onClick={() => onProfileUpdate({ full_name: 'Updated' })}>Update Profile</button><button onClick={() => onLoginSuccess({}, 'token')}>Login</button></div> : null }));
|
||||
vi.mock('./features/voice-assistant/VoiceAssistant', () => ({ VoiceAssistant: ({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) => isOpen ? <div data-testid="voice-assistant-mock"><button onClick={onClose}>Close Voice Assistant</button></div> : null }));
|
||||
vi.mock('./pages/admin/AdminPage', () => ({ AdminPage: () => <div data-testid="admin-page-mock">Admin Page</div> }));
|
||||
// In react-router v6, wrapper routes must render an <Outlet /> for nested routes to appear.
|
||||
vi.mock('./components/AdminRoute', () => ({ AdminRoute: ({ children }: { children: React.ReactNode }) => <div data-testid="admin-route-mock">{children || <Outlet />}</div> }));
|
||||
|
||||
@@ -7,7 +7,13 @@ import config from '../config';
|
||||
|
||||
// Mock the new config module
|
||||
vi.mock('../config', () => ({
|
||||
default: { google: { mapsEmbedApiKey: undefined } },
|
||||
default: {
|
||||
app: {
|
||||
version: '1.0.0',
|
||||
commitMessage: 'Initial commit',
|
||||
commitUrl: '#',
|
||||
},
|
||||
google: { mapsEmbedApiKey: undefined } },
|
||||
}));
|
||||
|
||||
describe('MapView', () => {
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('FlyerUploader', { timeout: 20000 }, () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText('Upload New Flyer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Click to select a file')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select a flyer (PDF or image) to begin.')).toBeInTheDocument();
|
||||
expect(screen.getByText('or drag and drop a PDF or image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle file upload and start polling', async () => {
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
// src/routes/admin.routes.ts
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import passport from '../routes/passport.routes';
|
||||
import { isAdmin } from '../routes/passport.routes'; // Correctly imported
|
||||
import multer from 'multer';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import * as db from '../services/db/index.db';
|
||||
import { logger } from '../services/logger.server';
|
||||
import { UserProfile } from '../types';
|
||||
import { clearGeocodeCache } from '../services/geocodingService.server';
|
||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||
|
||||
// --- Bull Board (Job Queue UI) Imports ---
|
||||
import { createBullBoard } from '@bull-board/api';
|
||||
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||||
import { ExpressAdapter } from '@bull-board/express';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import type { Queue } from 'bullmq';
|
||||
import { backgroundJobService } from '../services/backgroundJobService';
|
||||
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue, flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker } from '../services/queueService.server'; // Import your queues
|
||||
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Multer Configuration for File Uploads ---
|
||||
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
cb(null, storagePath);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
|
||||
// --- Bull Board (Job Queue UI) Setup ---
|
||||
const serverAdapter = new ExpressAdapter();
|
||||
serverAdapter.setBasePath('/api/admin/jobs'); // Set the base path for the UI
|
||||
|
||||
createBullBoard({
|
||||
queues: [
|
||||
new BullMQAdapter(flyerQueue),
|
||||
new BullMQAdapter(emailQueue),
|
||||
new BullMQAdapter(analyticsQueue),
|
||||
new BullMQAdapter(cleanupQueue),
|
||||
new BullMQAdapter(weeklyAnalyticsQueue), // Add the weekly analytics queue to the board
|
||||
],
|
||||
options: {
|
||||
uiConfig: {
|
||||
boardTitle: 'Bull Dashboard',
|
||||
},
|
||||
},
|
||||
serverAdapter: serverAdapter,
|
||||
});
|
||||
|
||||
// Mount the Bull Board UI router. This must be done BEFORE the isAdmin middleware
|
||||
// so the UI's own assets can be served, but the routes within it will be protected
|
||||
// by the router-level `isAdmin` middleware below.
|
||||
router.use('/jobs', serverAdapter.getRouter());
|
||||
|
||||
// --- Middleware for all admin routes ---
|
||||
router.use(passport.authenticate('jwt', { session: false }), isAdmin);
|
||||
|
||||
// --- Admin Routes ---
|
||||
|
||||
router.get('/corrections', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const corrections = await db.adminRepo.getSuggestedCorrections();
|
||||
res.json(corrections);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/brands', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const brands = await db.flyerRepo.getAllBrands();
|
||||
res.json(brands);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const stats = await db.adminRepo.getApplicationStats();
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats/daily', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days();
|
||||
res.json(dailyStats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/corrections/:id/approve', async (req, res, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
// Add validation to ensure the ID is a valid number.
|
||||
if (isNaN(correctionId)) {
|
||||
return res.status(400).json({ message: 'Invalid correction ID provided.' });
|
||||
}
|
||||
try {
|
||||
await db.adminRepo.approveCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction approved successfully.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/corrections/:id/reject', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
await db.adminRepo.rejectCorrection(correctionId);
|
||||
res.status(200).json({ message: 'Correction rejected successfully.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/corrections/:id', async (req, res, next: NextFunction) => {
|
||||
const correctionId = parseInt(req.params.id, 10);
|
||||
const { suggested_value } = req.body;
|
||||
if (!suggested_value) {
|
||||
return res.status(400).json({ message: 'A new suggested_value is required.' });
|
||||
}
|
||||
try {
|
||||
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(correctionId, suggested_value);
|
||||
res.status(200).json(updatedCorrection);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/recipes/:id/status', async (req, res, next: NextFunction) => {
|
||||
const recipeId = parseInt(req.params.id, 10);
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status || !['private', 'pending_review', 'public', 'rejected'].includes(status)) {
|
||||
return res.status(400).json({ message: 'A valid status (private, pending_review, public, rejected) is required.' });
|
||||
}
|
||||
try {
|
||||
const updatedRecipe = await db.adminRepo.updateRecipeStatus(recipeId, status); // This is still a standalone function in admin.db.ts
|
||||
res.status(200).json(updatedRecipe);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/brands/:id/logo', upload.single('logoImage'), async (req, res, next: NextFunction) => {
|
||||
const brandId = parseInt(req.params.id, 10);
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Logo image file is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const logoUrl = `/assets/${req.file.filename}`;
|
||||
await db.adminRepo.updateBrandLogo(brandId, logoUrl);
|
||||
|
||||
logger.info(`Brand logo updated for brand ID: ${brandId}`, { brandId, logoUrl });
|
||||
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/unmatched-items', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const items = await db.adminRepo.getUnmatchedFlyerItems();
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
|
||||
*/
|
||||
router.delete('/recipes/:recipeId', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
const recipeId = parseInt(req.params.recipeId, 10);
|
||||
|
||||
if (isNaN(recipeId)) {
|
||||
return res.status(400).json({ message: 'Invalid recipe ID.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// The isAdmin flag bypasses the ownership check in the repository method.
|
||||
await db.recipeRepo.deleteRecipe(recipeId, adminUser.user_id, true);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/flyers/:flyerId - Admin endpoint to delete a flyer and its items.
|
||||
*/
|
||||
router.delete('/flyers/:flyerId', async (req, res, next: NextFunction) => {
|
||||
const flyerId = parseInt(req.params.flyerId, 10);
|
||||
|
||||
if (isNaN(flyerId)) {
|
||||
return res.status(400).json({ message: 'Invalid flyer ID.' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.flyerRepo.deleteFlyer(flyerId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/comments/:id/status', async (req, res, next: NextFunction) => {
|
||||
const commentId = parseInt(req.params.id, 10);
|
||||
const { status } = req.body;
|
||||
|
||||
if (!status || !['visible', 'hidden', 'reported'].includes(status as string)) {
|
||||
return res.status(400).json({ message: 'A valid status (visible, hidden, reported) is required.' });
|
||||
}
|
||||
try {
|
||||
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(commentId, status); // This is still a standalone function in admin.db.ts
|
||||
res.status(200).json(updatedComment);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const users = await db.adminRepo.getAllUsers();
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/activity-log', async (req, res, next: NextFunction) => {
|
||||
const limit = parseInt(req.query.limit as string, 10) || 50;
|
||||
const offset = parseInt(req.query.offset as string, 10) || 0;
|
||||
try {
|
||||
const logs = await db.adminRepo.getActivityLog(limit, offset);
|
||||
res.json(logs);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/:id', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const user = await db.userRepo.findUserProfileById(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/users/:id', async (req, res, next: NextFunction) => {
|
||||
const { role } = req.body;
|
||||
if (!role || !['user', 'admin'].includes(role)) {
|
||||
return res.status(400).json({ message: 'A valid role ("user" or "admin") is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = await db.adminRepo.updateUserRole(req.params.id, role);
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
if (error instanceof ForeignKeyConstraintError) { // This error is thrown by the DB layer
|
||||
return res.status(404).json({ message: `User with ID ${req.params.id} not found.` });
|
||||
} else if (error instanceof Error && error.message.includes('not found')) { // This handles the generic "not found" from the repo
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
logger.error(`Error updating user ${req.params.id}:`, { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/users/:id', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
if (adminUser.user.user_id === req.params.id) {
|
||||
return res.status(400).json({ message: 'Admins cannot delete their own account.' });
|
||||
}
|
||||
try {
|
||||
await db.userRepo.deleteUserById(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/trigger/daily-deal-check - Manually trigger the daily deal check job.
|
||||
* This is useful for testing or forcing an update without waiting for the cron schedule.
|
||||
*/
|
||||
router.post('/trigger/daily-deal-check', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
logger.info(`[Admin] Manual trigger for daily deal check received from user: ${adminUser.user_id}`);
|
||||
|
||||
try {
|
||||
// We call the function but don't wait for it to finish (no `await`).
|
||||
// This is a "fire-and-forget" operation from the client's perspective.
|
||||
backgroundJobService.runDailyDealCheck();
|
||||
res.status(202).json({ message: 'Daily deal check job has been triggered successfully. It will run in the background.' });
|
||||
} catch (error) {
|
||||
logger.error('[Admin] Failed to trigger daily deal check job.', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/trigger/analytics-report - Manually enqueue a job to generate the daily analytics report.
|
||||
* This is useful for testing or re-generating a report without waiting for the cron schedule.
|
||||
*/
|
||||
router.post('/trigger/analytics-report', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
logger.info(`[Admin] Manual trigger for analytics report generation received from user: ${adminUser.user_id}`);
|
||||
|
||||
try {
|
||||
const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
// Use a unique job ID for manual triggers to distinguish them from scheduled jobs.
|
||||
const jobId = `manual-report-${reportDate}-${Date.now()}`;
|
||||
|
||||
const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId });
|
||||
|
||||
res.status(202).json({ message: `Analytics report generation job has been enqueued successfully. Job ID: ${job.id}` });
|
||||
} catch (error) {
|
||||
logger.error('[Admin] Failed to enqueue analytics report job.', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/flyers/:flyerId/cleanup - Enqueue a job to clean up a flyer's files.
|
||||
* This is triggered by an admin after they have verified the flyer processing was successful.
|
||||
*/
|
||||
router.post('/flyers/:flyerId/cleanup', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
const flyerId = parseInt(req.params.flyerId, 10);
|
||||
|
||||
if (isNaN(flyerId)) {
|
||||
return res.status(400).json({ message: 'A valid flyer ID is required.' });
|
||||
}
|
||||
|
||||
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${flyerId}`);
|
||||
|
||||
// Enqueue the cleanup job. The worker will handle the file deletion.
|
||||
try {
|
||||
await cleanupQueue.add('cleanup-flyer-files', { flyerId });
|
||||
res.status(202).json({ message: `File cleanup job for flyer ID ${flyerId} has been enqueued.` });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/trigger/failing-job - Enqueue a test job designed to fail.
|
||||
* This is for testing the retry mechanism and Bull Board UI.
|
||||
*/
|
||||
router.post('/trigger/failing-job', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
logger.info(`[Admin] Manual trigger for a failing job received from user: ${adminUser.user_id}`);
|
||||
|
||||
try {
|
||||
// Add a job with a special 'forceFail' flag that the worker will recognize.
|
||||
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
|
||||
res.status(202).json({ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/system/clear-geocode-cache - Clears the Redis cache for geocoded addresses.
|
||||
* Requires admin privileges.
|
||||
*/
|
||||
router.post('/system/clear-geocode-cache', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
logger.info(`[Admin] Manual trigger for geocode cache clear received from user: ${adminUser.user_id}`);
|
||||
|
||||
try {
|
||||
const keysDeleted = await clearGeocodeCache();
|
||||
res.status(200).json({ message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.` });
|
||||
} catch (error) {
|
||||
logger.error('[Admin] Failed to clear geocode cache.', { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/workers/status - Get the current running status of all BullMQ workers.
|
||||
* This is useful for a system health dashboard to see if any workers have crashed.
|
||||
*/
|
||||
router.get('/workers/status', async (req, res) => {
|
||||
const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker ];
|
||||
|
||||
const workerStatuses = await Promise.all(
|
||||
workers.map(async (worker) => {
|
||||
return {
|
||||
name: worker.name,
|
||||
isRunning: worker.isRunning(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(workerStatuses);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/queues/status - Get job counts for all BullMQ queues.
|
||||
* This is useful for monitoring the health and backlog of background jobs.
|
||||
*/
|
||||
router.get('/queues/status', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const queues = [flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue];
|
||||
|
||||
const queueStatuses = await Promise.all(
|
||||
queues.map(async (queue) => {
|
||||
return {
|
||||
name: queue.name,
|
||||
counts: await queue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed', 'paused'),
|
||||
};
|
||||
})
|
||||
);
|
||||
res.json(queueStatuses);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/jobs/:queueName/:jobId/retry - Retries a specific failed job.
|
||||
*/
|
||||
router.post('/jobs/:queueName/:jobId/retry', async (req, res, next: NextFunction) => {
|
||||
const { queueName, jobId } = req.params;
|
||||
const adminUser = req.user as UserProfile;
|
||||
|
||||
const queueMap: { [key: string]: Queue } = {
|
||||
'flyer-processing': flyerQueue,
|
||||
'email-sending': emailQueue,
|
||||
'analytics-reporting': analyticsQueue,
|
||||
'file-cleanup': cleanupQueue,
|
||||
};
|
||||
|
||||
const queue = queueMap[queueName];
|
||||
|
||||
if (!queue) {
|
||||
return res.status(404).json({ message: `Queue '${queueName}' not found.` });
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await queue.getJob(jobId);
|
||||
if (!job) {
|
||||
return res.status(404).json({ message: `Job with ID '${jobId}' not found in queue '${queueName}'.` });
|
||||
}
|
||||
|
||||
const jobState = await job.getState();
|
||||
if (jobState !== 'failed') {
|
||||
return res.status(400).json({ message: `Job is not in a 'failed' state. Current state: ${jobState}.` });
|
||||
}
|
||||
|
||||
await job.retry();
|
||||
logger.info(`[Admin] User ${adminUser.user_id} manually retried job ${jobId} in queue ${queueName}.`);
|
||||
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/trigger/weekly-analytics - Manually trigger the weekly analytics report job.
|
||||
*/
|
||||
router.post('/trigger/weekly-analytics', async (req, res, next: NextFunction) => {
|
||||
const adminUser = req.user as UserProfile;
|
||||
logger.info(`[Admin] Manual trigger for weekly analytics report received from user: ${adminUser.user_id}`);
|
||||
|
||||
try {
|
||||
const { year: reportYear, week: reportWeek } = getSimpleWeekAndYear();
|
||||
const { weeklyAnalyticsQueue } = await import('../services/queueService.server');
|
||||
const job = await weeklyAnalyticsQueue.add('generate-weekly-report', { reportYear, reportWeek }, {
|
||||
jobId: `manual-weekly-report-${reportYear}-${reportWeek}-${Date.now()}` // Add timestamp to avoid ID conflict
|
||||
});
|
||||
|
||||
res.status(202).json({ message: 'Successfully enqueued weekly analytics job.', jobId: job.id });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/middleware/errorHandler.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { DatabaseError, UniqueConstraintError, ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||
import { DatabaseError, UniqueConstraintError, ForeignKeyConstraintError, NotFoundError, ValidationError } from '../services/db/errors.db';
|
||||
import crypto from 'crypto';
|
||||
import { logger } from '../services/logger.server';
|
||||
|
||||
@@ -19,15 +19,9 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
|
||||
let message = err.message;
|
||||
|
||||
// --- Handle Specific Custom Error Types ---
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
statusCode = 409; // Conflict
|
||||
message = err.message || 'The record already exists.';
|
||||
} else if (err instanceof ForeignKeyConstraintError) {
|
||||
statusCode = 400; // Bad Request
|
||||
message = err.message || 'A related record was not found.';
|
||||
} else if (err instanceof DatabaseError) {
|
||||
// For other generic database errors, ensure a 500 status
|
||||
statusCode = 500;
|
||||
// All our custom errors inherit from DatabaseError and have a status property.
|
||||
if (err instanceof DatabaseError) {
|
||||
statusCode = err.status || 500;
|
||||
}
|
||||
|
||||
// --- TEST ENVIRONMENT DEBUGGING ---
|
||||
@@ -46,6 +40,14 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
|
||||
method: req.method,
|
||||
body: req.body,
|
||||
});
|
||||
} else {
|
||||
// For 4xx errors, log at a lower level (e.g., 'warn') to avoid flooding error trackers.
|
||||
logger.warn(`Client Error: ${statusCode} on ${req.method} ${req.path}`, {
|
||||
errorMessage: message,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
});
|
||||
}
|
||||
|
||||
// In production, send a generic message for 5xx errors.
|
||||
|
||||
@@ -137,11 +137,8 @@ router.put('/corrections/:id', async (req, res, next: NextFunction) => {
|
||||
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(correctionId, suggested_value);
|
||||
res.status(200).json(updatedCorrection);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
// The custom error handler now correctly interprets "not found" messages.
|
||||
// We can simplify this by just passing the error along.
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -157,11 +154,6 @@ router.put('/recipes/:id/status', async (req, res, next: NextFunction) => {
|
||||
const updatedRecipe = await db.adminRepo.updateRecipeStatus(recipeId, status); // This is still a standalone function in admin.db.ts
|
||||
res.status(200).json(updatedRecipe);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -207,12 +199,7 @@ router.delete('/recipes/:recipeId', async (req, res, next: NextFunction) => {
|
||||
// The isAdmin flag bypasses the ownership check in the repository method.
|
||||
await db.recipeRepo.deleteRecipe(recipeId, adminUser.user_id, true);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -230,12 +217,7 @@ router.delete('/flyers/:flyerId', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
await db.flyerRepo.deleteFlyer(flyerId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -250,12 +232,7 @@ router.put('/comments/:id/status', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(commentId, status); // This is still a standalone function in admin.db.ts
|
||||
res.status(200).json(updatedComment);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -283,9 +260,6 @@ router.get('/activity-log', async (req, res, next: NextFunction) => {
|
||||
router.get('/users/:id', async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const user = await db.userRepo.findUserProfileById(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -293,7 +293,7 @@ describe('Auth Routes (/api/auth)', () => {
|
||||
.send({ email: 'locked@test.com', password: 'password123' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.message).toBe('Account is temporarily locked.');
|
||||
expect(response.body.message).toBe('Account is temporarily locked. Please try again in 15 minutes.');
|
||||
});
|
||||
|
||||
it('should return 401 if user is not found', async () => {
|
||||
|
||||
@@ -55,12 +55,7 @@ router.put('/:id', validateNumericParams(['id']), async (req, res, next: NextFun
|
||||
try {
|
||||
const updatedBudget = await budgetRepo.updateBudget(budgetId, user.user_id, req.body);
|
||||
res.json(updatedBudget);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error updating budget:', { error, userId: user.user_id, budgetId });
|
||||
next(error);
|
||||
}
|
||||
@@ -75,12 +70,7 @@ router.delete('/:id', validateNumericParams(['id']), async (req, res, next: Next
|
||||
try {
|
||||
await budgetRepo.deleteBudget(budgetId, user.user_id);
|
||||
res.status(204).send(); // No Content
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error deleting budget:', { error, userId: user.user_id, budgetId });
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import express, { Request, Response, NextFunction } from 'express';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
import flyerRouter from './flyer.routes';
|
||||
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
|
||||
import { NotFoundError } from '../services/db/errors.db';
|
||||
|
||||
// 1. Mock the Service Layer directly.
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
@@ -78,8 +79,9 @@ describe('Flyer Routes (/api/flyers)', () => {
|
||||
});
|
||||
|
||||
it('should return 404 if the flyer is not found', async () => {
|
||||
vi.mocked(db.flyerRepo.getFlyerById).mockResolvedValue(undefined);
|
||||
vi.mocked(db.flyerRepo.getFlyerById).mockRejectedValue(new NotFoundError('Flyer not found'));
|
||||
const response = await supertest(app).get('/api/flyers/999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toContain('not found');
|
||||
});
|
||||
|
||||
@@ -31,12 +31,8 @@ router.get('/:id', async (req, res, next: NextFunction) => {
|
||||
return res.status(400).json({ message: 'Invalid flyer ID provided.' });
|
||||
}
|
||||
const flyer = await db.flyerRepo.getFlyerById(flyerId);
|
||||
if (!flyer) {
|
||||
return res.status(404).json({ message: `Flyer with ID ${flyerId} not found.` });
|
||||
}
|
||||
res.json(flyer);
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching flyer ID ${req.params.id}:`, { error });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/routes/middleware/validation.middleware.ts
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { ValidationError } from '../../services/db/errors.db';
|
||||
|
||||
/**
|
||||
* A simple validation middleware to check for required fields in the request body.
|
||||
@@ -9,7 +10,7 @@ export const validateBody = (requiredFields: string[]) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
for (const field of requiredFields) {
|
||||
if (!req.body[field]) {
|
||||
return res.status(400).json({ message: `Field '${field}' is required.` });
|
||||
return next(new ValidationError(`Field '${field}' is required.`));
|
||||
}
|
||||
}
|
||||
next();
|
||||
@@ -24,7 +25,7 @@ export const validateNumericParams = (params: string[]) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
for (const param of params) {
|
||||
if (isNaN(parseInt(req.params[param], 10))) {
|
||||
return res.status(400).json({ message: `Invalid ID for parameter '${param}'. Must be a number.` });
|
||||
return next(new ValidationError(`Invalid ID for parameter '${param}'. Must be a number.`));
|
||||
}
|
||||
}
|
||||
next();
|
||||
|
||||
@@ -5,6 +5,7 @@ import express, { Request, Response, NextFunction } from 'express';
|
||||
import recipeRouter from './recipe.routes';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
import { createMockRecipe, createMockRecipeComment } from '../tests/utils/mockFactories';
|
||||
import { NotFoundError } from '../services/db/errors.db';
|
||||
|
||||
// 1. Mock the Service Layer directly.
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
@@ -185,7 +186,7 @@ describe('Recipe Routes (/api/recipes)', () => {
|
||||
});
|
||||
|
||||
it('should return 404 if the recipe is not found', async () => {
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockResolvedValue(undefined);
|
||||
vi.mocked(db.recipeRepo.getRecipeById).mockRejectedValue(new NotFoundError('Recipe not found'));
|
||||
const response = await supertest(app).get('/api/recipes/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toContain('not found');
|
||||
|
||||
@@ -71,9 +71,6 @@ router.get('/:recipeId', async (req: Request, res: Response, next: NextFunction)
|
||||
return res.status(400).json({ message: 'Invalid recipe ID provided.' });
|
||||
}
|
||||
const recipe = await db.recipeRepo.getRecipeById(recipeId);
|
||||
if (!recipe) {
|
||||
return res.status(404).json({ message: `Recipe with ID ${recipeId} not found.` });
|
||||
}
|
||||
res.json(recipe);
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching recipe ID ${req.params.recipeId}:`, { error });
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as bcrypt from 'bcrypt';
|
||||
import userRouter from './user.routes';
|
||||
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockRecipe } from '../tests/utils/mockFactories';
|
||||
import { Appliance, Notification } from '../types';
|
||||
import { ForeignKeyConstraintError } from '../services/db/errors.db';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
|
||||
import { errorHandler } from '../middleware/errorHandler';
|
||||
|
||||
// 1. Mock the Service Layer directly.
|
||||
@@ -143,10 +143,10 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
it('should return 404 if profile is not found in DB', async () => {
|
||||
vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(undefined);
|
||||
vi.mocked(db.userRepo.findUserProfileById).mockRejectedValue(new NotFoundError('Profile not found for this user.'));
|
||||
const response = await supertest(app).get('/api/users/profile');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe('Profile not found for this user.');
|
||||
expect(response.body.message).toContain('Profile not found');
|
||||
});
|
||||
|
||||
it('should return 500 if the database call fails', async () => {
|
||||
@@ -888,10 +888,10 @@ describe('User Routes (/api/users)', () => {
|
||||
});
|
||||
|
||||
it('GET /shopping-lists/:listId should return 404 if list is not found', async () => {
|
||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockResolvedValue(undefined);
|
||||
vi.mocked(db.shoppingRepo.getShoppingListById).mockRejectedValue(new NotFoundError('Shopping list not found'));
|
||||
const response = await supertest(app).get('/api/users/shopping-lists/999');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toContain('not found');
|
||||
expect(response.body.message).toBe('Shopping list not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -323,12 +323,7 @@ router.delete('/shopping-lists/:listId', validateNumericParams(['listId']), asyn
|
||||
try {
|
||||
await db.shoppingRepo.deleteShoppingList(listId, user.user_id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
|
||||
logger.error(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ERROR`, { errorMessage, params: req.params });
|
||||
next(error);
|
||||
@@ -366,12 +361,7 @@ router.put('/shopping-lists/items/:itemId', validateNumericParams(['itemId']), a
|
||||
try {
|
||||
const updatedItem = await db.shoppingRepo.updateShoppingListItem(itemId, req.body);
|
||||
res.json(updatedItem);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ERROR`, { error, params: req.params, body: req.body });
|
||||
next(error);
|
||||
}
|
||||
@@ -386,12 +376,7 @@ router.delete('/shopping-lists/items/:itemId', validateNumericParams(['itemId'])
|
||||
try {
|
||||
await db.shoppingRepo.removeShoppingListItem(itemId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ERROR`, { error, params: req.params });
|
||||
next(error);
|
||||
}
|
||||
@@ -497,9 +482,6 @@ router.get('/addresses/:addressId', validateNumericParams(['addressId']), async
|
||||
|
||||
try {
|
||||
const address = await db.addressRepo.getAddressById(addressId);
|
||||
if (!address) {
|
||||
return res.status(404).json({ message: 'Address not found.' });
|
||||
}
|
||||
res.json(address);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -534,16 +516,9 @@ router.delete('/recipes/:recipeId', validateNumericParams(['recipeId']), async (
|
||||
const recipeId = parseInt(req.params.recipeId, 10);
|
||||
|
||||
try {
|
||||
// The deleteRecipe method handles the ownership check.
|
||||
// We pass `false` for isAdmin because this is the user-facing route.
|
||||
await db.recipeRepo.deleteRecipe(recipeId, user.user_id, false);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -557,17 +532,9 @@ router.put('/recipes/:recipeId', validateNumericParams(['recipeId']), async (req
|
||||
const recipeId = parseInt(req.params.recipeId, 10);
|
||||
|
||||
try {
|
||||
// The updateRecipe method handles the ownership check.
|
||||
const updatedRecipe = await db.recipeRepo.updateRecipe(recipeId, user.user_id, req.body);
|
||||
res.json(updatedRecipe);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
const err = new Error(error.message);
|
||||
(err as any).status = 404;
|
||||
return next(err);
|
||||
} else if (error instanceof Error && error.message.includes('No fields provided')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/services/db/admin.db.ts
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import { ForeignKeyConstraintError } from './errors.db';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import { logger } from '../logger.server';
|
||||
import { SuggestedCorrection, MostFrequentSaleItem, Recipe, RecipeComment, UnmatchedFlyerItem, ActivityLogItem, Receipt, User, AdminUserView } from '../../types';
|
||||
|
||||
@@ -103,7 +103,7 @@ export class AdminRepository {
|
||||
[newSuggestedValue, correctionId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error(`Correction with ID ${correctionId} not found or is not in 'pending' state.`);
|
||||
throw new NotFoundError(`Correction with ID ${correctionId} not found or is not in 'pending' state.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
@@ -250,7 +250,7 @@ export class AdminRepository {
|
||||
[status, commentId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error(`Recipe comment with ID ${commentId} not found.`);
|
||||
throw new NotFoundError(`Recipe comment with ID ${commentId} not found.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
@@ -303,7 +303,7 @@ export class AdminRepository {
|
||||
[status, recipeId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error(`Recipe with ID ${recipeId} not found.`);
|
||||
throw new NotFoundError(`Recipe with ID ${recipeId} not found.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
@@ -330,7 +330,7 @@ export class AdminRepository {
|
||||
);
|
||||
|
||||
if (unmatchedRes.rowCount === 0) {
|
||||
throw new Error(`Unmatched flyer item with ID ${unmatchedFlyerItemId} not found.`);
|
||||
throw new NotFoundError(`Unmatched flyer item with ID ${unmatchedFlyerItemId} not found.`);
|
||||
}
|
||||
const { flyer_item_id } = unmatchedRes.rows[0];
|
||||
|
||||
@@ -510,7 +510,7 @@ export class AdminRepository {
|
||||
[role, userId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error(`User with ID ${userId} not found.`);
|
||||
throw new NotFoundError(`User with ID ${userId} not found.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/services/db/budget.db.ts
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import { ForeignKeyConstraintError } from './errors.db';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import { logger } from '../logger.server';
|
||||
import { Budget, SpendingByCategory } from '../../types';
|
||||
|
||||
@@ -83,12 +83,9 @@ export class BudgetRepository {
|
||||
WHERE budget_id = $5 AND user_id = $6 RETURNING *`,
|
||||
[name, amount_cents, period, start_date, budgetId, userId],
|
||||
);
|
||||
if (res.rowCount === 0) throw new Error('Budget not found or user does not have permission to update.');
|
||||
if (res.rowCount === 0) throw new NotFoundError('Budget not found or user does not have permission to update.');
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Budget not found')) {
|
||||
throw error; // Re-throw the specific error to the caller
|
||||
}
|
||||
logger.error('Database error in updateBudget:', { error, budgetId, userId });
|
||||
throw new Error('Failed to update budget.');
|
||||
}
|
||||
@@ -103,12 +100,9 @@ export class BudgetRepository {
|
||||
try {
|
||||
const result = await this.db.query('DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2', [budgetId, userId]);
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error('Budget not found or user does not have permission to delete.');
|
||||
throw new NotFoundError('Budget not found or user does not have permission to delete.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Budget not found')) {
|
||||
throw error; // Re-throw the specific error to the caller
|
||||
}
|
||||
logger.error('Database error in deleteBudget:', { error, budgetId, userId });
|
||||
throw new Error('Failed to delete budget.');
|
||||
}
|
||||
|
||||
@@ -33,4 +33,22 @@ export class ForeignKeyConstraintError extends DatabaseError {
|
||||
constructor(message = 'The referenced record does not exist.') {
|
||||
super(message, 400); // 400 Bad Request
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a specific record is not found in the database.
|
||||
*/
|
||||
export class NotFoundError extends DatabaseError {
|
||||
constructor(message = 'The requested resource was not found.') {
|
||||
super(message, 404); // 404 Not Found
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when request validation fails (e.g., missing body fields or invalid params).
|
||||
*/
|
||||
export class ValidationError extends DatabaseError {
|
||||
constructor(message = 'The request data is invalid.') {
|
||||
super(message, 400); // 400 Bad Request
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import { logger } from '../logger.server';
|
||||
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
|
||||
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import type { Flyer, FlyerItem, FlyerInsert, FlyerItemInsert, Brand, FlyerDbInsert } from '../../types';
|
||||
|
||||
export class FlyerRepository {
|
||||
@@ -154,9 +154,12 @@ export class FlyerRepository {
|
||||
* @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 | undefined> {
|
||||
async getFlyerById(flyerId: number): Promise<Flyer> {
|
||||
try {
|
||||
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];
|
||||
} catch (error) {
|
||||
logger.error('Database error in getFlyerById:', { error, flyerId });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/services/db/recipe.db.ts
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import { ForeignKeyConstraintError } from './errors.db';
|
||||
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import { logger } from '../logger.server';
|
||||
import { Recipe, FavoriteRecipe, RecipeComment } from '../../types';
|
||||
|
||||
@@ -87,9 +87,6 @@ export class RecipeRepository {
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === '23503') {
|
||||
throw new ForeignKeyConstraintError('The specified user or recipe does not exist.');
|
||||
}
|
||||
logger.error('Database error in addFavoriteRecipe:', { error, userId, recipeId });
|
||||
throw new Error('Failed to add favorite recipe.');
|
||||
}
|
||||
@@ -104,10 +101,9 @@ export class RecipeRepository {
|
||||
try {
|
||||
const res = await this.db.query('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', [userId, recipeId]);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error('Favorite recipe not found for this user.');
|
||||
throw new NotFoundError('Favorite recipe not found for this user.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Favorite recipe not found')) throw error;
|
||||
logger.error('Database error in removeFavoriteRecipe:', { error, userId, recipeId });
|
||||
throw new Error('Failed to remove favorite recipe.');
|
||||
}
|
||||
@@ -131,11 +127,10 @@ export class RecipeRepository {
|
||||
|
||||
const res = await this.db.query(query, params);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error('Recipe not found or user does not have permission to delete.');
|
||||
throw new NotFoundError('Recipe not found or user does not have permission to delete.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Recipe not found')) throw error;
|
||||
logger.error('Database error in deleteRecipe:', { error, recipeId, userId });
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
throw new Error('Failed to delete recipe.');
|
||||
}
|
||||
}
|
||||
@@ -175,10 +170,14 @@ export class RecipeRepository {
|
||||
|
||||
const res = await this.db.query<Recipe>(query, values);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error('Recipe not found or user does not have permission to update.');
|
||||
throw new NotFoundError('Recipe not found or user does not have permission to update.');
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
// Re-throw specific, known errors to allow for more precise error handling in the calling code.
|
||||
if (error instanceof NotFoundError || (error instanceof Error && error.message.includes('No fields provided'))) {
|
||||
throw error;
|
||||
}
|
||||
logger.error('Database error in updateRecipe:', { error, recipeId, userId });
|
||||
throw new Error('Failed to update recipe.');
|
||||
}
|
||||
@@ -189,7 +188,7 @@ export class RecipeRepository {
|
||||
* @param recipeId The ID of the recipe to retrieve.
|
||||
* @returns A promise that resolves to the Recipe object or undefined if not found.
|
||||
*/
|
||||
async getRecipeById(recipeId: number): Promise<Recipe | undefined> {
|
||||
async getRecipeById(recipeId: number): Promise<Recipe> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
@@ -205,6 +204,9 @@ export class RecipeRepository {
|
||||
GROUP BY r.recipe_id;
|
||||
`;
|
||||
const res = await this.db.query<Recipe>(query, [recipeId]);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError(`Recipe with ID ${recipeId} not found.`);
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in getRecipeById:', { error, recipeId });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/services/db/shopping.db.ts
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import { ForeignKeyConstraintError, UniqueConstraintError } from './errors.db';
|
||||
import { getPool } from './connection.db';
|
||||
import { ForeignKeyConstraintError, UniqueConstraintError, NotFoundError } from './errors.db';
|
||||
import { logger } from '../logger.server';
|
||||
import {
|
||||
ShoppingList,
|
||||
@@ -88,7 +88,7 @@ export class ShoppingRepository {
|
||||
* @param userId The ID of the user requesting the list.
|
||||
* @returns A promise that resolves to the ShoppingList object or undefined if not found or not owned by the user.
|
||||
*/
|
||||
async getShoppingListById(listId: number, userId: string): Promise<ShoppingList | undefined> {
|
||||
async getShoppingListById(listId: number, userId: string): Promise<ShoppingList> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
@@ -112,6 +112,9 @@ export class ShoppingRepository {
|
||||
GROUP BY sl.shopping_list_id;
|
||||
`;
|
||||
const res = await this.db.query<ShoppingList>(query, [listId, userId]);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError('Shopping list not found or you do not have permission to view it.');
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in getShoppingListById:', { error, listId, userId });
|
||||
@@ -129,10 +132,10 @@ export class ShoppingRepository {
|
||||
const res = await this.db.query('DELETE FROM public.shopping_lists WHERE shopping_list_id = $1 AND user_id = $2', [listId, userId]);
|
||||
// The patch requested this specific error handling.
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error('Shopping list not found or user does not have permission to delete.');
|
||||
throw new NotFoundError('Shopping list not found or user does not have permission to delete.');
|
||||
}
|
||||
} catch (error) {
|
||||
// The patch requested this specific error handling.
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
logger.error('Database error in deleteShoppingList:', { error, listId, userId });
|
||||
throw new Error('Failed to delete shopping list.');
|
||||
}
|
||||
@@ -175,11 +178,10 @@ export class ShoppingRepository {
|
||||
const res = await this.db.query('DELETE FROM public.shopping_list_items WHERE shopping_list_item_id = $1', [itemId]);
|
||||
// The patch requested this specific error handling.
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error('Shopping list item not found.');
|
||||
throw new NotFoundError('Shopping list item not found.');
|
||||
}
|
||||
} catch (error) {
|
||||
// The patch requested this specific error handling.
|
||||
if (error instanceof Error && error.message.startsWith('Shopping list item not found')) throw error;
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
logger.error('Database error in removeShoppingListItem:', { error, itemId });
|
||||
throw new Error('Failed to remove item from shopping list.');
|
||||
}
|
||||
@@ -246,9 +248,8 @@ export class ShoppingRepository {
|
||||
);
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
// The patch requested this specific error handling.
|
||||
if ((error as any).code === '23505') {
|
||||
throw new UniqueConstraintError('Location name exists');
|
||||
throw new UniqueConstraintError('A pantry location with this name already exists.');
|
||||
} else if ((error as any).code === '23503') {
|
||||
throw new ForeignKeyConstraintError('User not found');
|
||||
}
|
||||
@@ -295,12 +296,12 @@ export class ShoppingRepository {
|
||||
const res = await this.db.query<ShoppingListItem>(query, values);
|
||||
// The patch requested this specific error handling.
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error('Shopping list item not found.');
|
||||
throw new NotFoundError('Shopping list item not found.');
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
// Re-throw specific, known errors to allow for more precise error handling in the calling code.
|
||||
if (error instanceof Error && (error.message.startsWith('Shopping list item not found') || error.message.startsWith('No valid fields'))) {
|
||||
if (error instanceof NotFoundError || (error instanceof Error && error.message.startsWith('No valid fields'))) {
|
||||
throw error;
|
||||
}
|
||||
logger.error('Database error in updateShoppingListItem:', { error, itemId, updates });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getPool } from './connection.db';
|
||||
import { logger } from '../logger.server';
|
||||
import { UniqueConstraintError, ForeignKeyConstraintError } from './errors.db';
|
||||
import { UniqueConstraintError, ForeignKeyConstraintError, NotFoundError } from './errors.db';
|
||||
import { Profile, MasterGroceryItem, ShoppingList, ActivityLogItem, UserProfile, SearchQuery } from '../../types';
|
||||
import { ShoppingRepository } from './shopping.db';
|
||||
import { PersonalizationRepository } from './personalization.db';
|
||||
@@ -114,12 +114,8 @@ export class UserRepository {
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
// Check for specific PostgreSQL error codes
|
||||
if (error instanceof Error && 'code' in error && error.code === '23505') { // unique_violation
|
||||
if (error instanceof Error && 'code' in error && error.code === '23505') {
|
||||
logger.warn(`Attempted to create a user with an existing email: ${email}`);
|
||||
// The patch requested this specific error handling.
|
||||
if ((error as any).code === '23505') {
|
||||
throw new UniqueConstraintError('A user with this email address already exists.');
|
||||
}
|
||||
throw new UniqueConstraintError('A user with this email address already exists.');
|
||||
}
|
||||
logger.error('Database transaction error in createUser:', { error });
|
||||
@@ -197,7 +193,7 @@ export class UserRepository {
|
||||
* @returns A promise that resolves to the user's profile object or undefined if not found.
|
||||
*/
|
||||
// prettier-ignore
|
||||
async findUserProfileById(userId: string): Promise<Profile | undefined> {
|
||||
async findUserProfileById(userId: string): Promise<Profile> {
|
||||
try {
|
||||
const res = await this.db.query<Profile>(
|
||||
`SELECT
|
||||
@@ -219,6 +215,9 @@ export class UserRepository {
|
||||
WHERE p.user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new NotFoundError('Profile not found for this user.');
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('Database error in findUserProfileById:', { error });
|
||||
@@ -260,11 +259,10 @@ export class UserRepository {
|
||||
query, values
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error('User not found or user does not have permission to update.');
|
||||
throw new NotFoundError('User not found or user does not have permission to update.');
|
||||
}
|
||||
return res.rows[0];
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('User not found')) throw error;
|
||||
logger.error('Database error in updateUserProfile:', { error });
|
||||
throw new Error('Failed to update user profile in database.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user