unit test fixes + error refactor
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 10m52s

This commit is contained in:
2025-12-11 18:23:59 -08:00
parent 5f1901b93d
commit 0bc65574c2
22 changed files with 112 additions and 676 deletions

View File

@@ -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> }));

View File

@@ -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', () => {

View File

@@ -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 () => {

View File

@@ -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;

View File

@@ -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.

View File

@@ -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);

View File

@@ -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 () => {

View File

@@ -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);
}

View File

@@ -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');
});

View File

@@ -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);
}
});

View File

@@ -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();

View File

@@ -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');

View File

@@ -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 });

View File

@@ -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');
});
});
});

View File

@@ -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);
}
});

View File

@@ -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) {

View File

@@ -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.');
}

View File

@@ -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
}
}

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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.');
}