feat: Enhance API validation and error handling across routes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled

- Added tests for invalid request bodies in price and recipe routes.
- Improved type safety in request handlers using Zod schemas.
- Introduced a consistent validation pattern for empty request bodies.
- Enhanced error messages for invalid query parameters in stats and user routes.
- Implemented middleware to inject mock logging for tests.
- Created a custom type for validated requests to streamline type inference.
This commit is contained in:
2025-12-14 12:53:10 -08:00
parent 9757f9dd9f
commit 56f14f6342
38 changed files with 703 additions and 273 deletions

View File

@@ -160,6 +160,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body).toEqual(mockUpdatedCorrection);
});
it('PUT /corrections/:id should return 400 for invalid data', async () => {
const response = await supertest(app)
.put('/api/admin/corrections/101')
.send({ suggested_value: '' }); // Send empty value
expect(response.status).toBe(400);
});
it('PUT /corrections/:id should return 404 if correction not found', async () => {
vi.mocked(mockedDb.adminRepo.updateSuggestedCorrection).mockRejectedValue(new NotFoundError('Correction with ID 999 not found'));
const response = await supertest(app).put('/api/admin/corrections/999').send({ suggested_value: 'new value' });
@@ -193,6 +200,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(400);
expect(response.body.message).toBe('Logo image file is required.');
});
it('POST /brands/:id/logo should return 400 for an invalid brand ID', async () => {
const response = await supertest(app).post('/api/admin/brands/abc/logo')
.attach('logoImage', Buffer.from('dummy-logo-content'), 'test-logo.png');
expect(response.status).toBe(400);
});
});
describe('Recipe and Comment Routes', () => {
@@ -206,6 +219,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body).toEqual(mockUpdatedRecipe);
});
it('PUT /recipes/:id/status should return 400 for an invalid status value', async () => {
const recipeId = 201;
const requestBody = { status: 'invalid_status' };
const response = await supertest(app).put(`/api/admin/recipes/${recipeId}/status`).send(requestBody);
expect(response.status).toBe(400);
});
it('PUT /comments/:id/status should update a comment status', async () => {
const commentId = 301;
const requestBody = { status: 'hidden' as const };
@@ -216,6 +236,13 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body).toEqual(mockUpdatedComment);
});
it('PUT /comments/:id/status should return 400 for an invalid status value', async () => {
const commentId = 301;
const requestBody = { status: 'invalid_status' };
const response = await supertest(app).put(`/api/admin/comments/${commentId}/status`).send(requestBody);
expect(response.status).toBe(400);
});
});
describe('Unmatched Items Route', () => {
@@ -254,5 +281,11 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(404);
expect(response.body.message).toBe('Flyer with ID 999 not found.');
});
it('DELETE /flyers/:flyerId should return 400 for an invalid flyerId', async () => {
const response = await supertest(app).delete('/api/admin/flyers/abc');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Expected number, received nan');
});
});
});

View File

@@ -181,6 +181,12 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('Queue is down');
});
it('should return 400 for an invalid flyerId', async () => {
const response = await supertest(app).post('/api/admin/flyers/abc/cleanup');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Expected number, received nan');
});
});
describe('POST /jobs/:queueName/:jobId/retry', () => {
@@ -246,5 +252,11 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toContain('Cannot retry job');
});
it('should return 400 for an invalid queueName or jobId', async () => {
// This tests the Zod schema validation for the route params.
const response = await supertest(app).post('/api/admin/jobs/ / /retry');
expect(response.status).toBe(400);
});
});
});

View File

@@ -143,6 +143,13 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
expect(adminRepo.getActivityLog).toHaveBeenCalledWith(10, 20);
});
it('should return 400 for invalid limit and offset query parameters', async () => {
const response = await supertest(app).get('/api/admin/activity-log?limit=abc&offset=-1');
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(2); // Both limit and offset are invalid
});
});
describe('GET /workers/status', () => {

View File

@@ -1,7 +1,7 @@
// src/routes/admin.routes.ts
import { Router, NextFunction } from 'express';
import { Router, NextFunction, Request, Response } from 'express';
import passport from './passport.routes';
import { isAdmin } from './passport.routes'; // Correctly imported
import { isAdmin, optionalAuth } from './passport.routes'; // Correctly imported
import multer from 'multer';// --- Zod Schemas for Admin Routes (as per ADR-003) ---
import { z } from 'zod';
@@ -23,6 +23,10 @@ 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 uuidParamSchema = (key: string) => z.object({
params: z.object({ [key]: z.string().uuid() }),
});
const numericIdParamSchema = (key: string) => z.object({
params: z.object({ [key]: z.coerce.number().int().positive() }),
});
@@ -46,7 +50,7 @@ const updateCommentStatusSchema = numericIdParamSchema('id').extend({
});
const updateUserRoleSchema = z.object({
params: z.object({ id: z.string().uuid("User ID must be a valid UUID.") }),
params: z.object({ id: z.string().uuid() }),
body: z.object({
role: z.enum(['user', 'admin']),
}),
@@ -59,6 +63,10 @@ const activityLogSchema = z.object({
}),
});
const jobRetrySchema = z.object({
params: z.object({ queueName: z.string(), jobId: z.string() }),
});
const router = Router();
// --- Multer Configuration for File Uploads ---
@@ -140,50 +148,52 @@ router.get('/stats/daily', async (req, res, next: NextFunction) => {
}
});
router.post('/corrections/:id/approve', validateRequest(numericIdParamSchema('id')), async (req, res, next: NextFunction) => {
router.post('/corrections/:id/approve', validateRequest(numericIdParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
type ApproveCorrectionRequest = z.infer<typeof updateCorrectionSchema>;
const { params } = req as unknown as ApproveCorrectionRequest;
try {
const correctionId = req.params.id as unknown as number;
await db.adminRepo.approveCorrection(correctionId, req.log);
await db.adminRepo.approveCorrection(params.id, req.log);
res.status(200).json({ message: 'Correction approved successfully.' });
} catch (error) {
next(error);
}
});
router.post('/corrections/:id/reject', validateRequest(numericIdParamSchema('id')), async (req, res, next: NextFunction) => {
router.post('/corrections/:id/reject', validateRequest(numericIdParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
type RejectCorrectionRequest = z.infer<typeof updateCorrectionSchema>;
const { params } = req as unknown as RejectCorrectionRequest;
try {
const correctionId = req.params.id as unknown as number;
await db.adminRepo.rejectCorrection(correctionId, req.log);
await db.adminRepo.rejectCorrection(params.id, req.log);
res.status(200).json({ message: 'Correction rejected successfully.' });
} catch (error) {
next(error);
}
});
router.put('/corrections/:id', validateRequest(updateCorrectionSchema), async (req, res, next: NextFunction) => {
const correctionId = req.params.id as unknown as number;
const { suggested_value } = req.body;
router.put('/corrections/:id', validateRequest(updateCorrectionSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateCorrectionRequest = z.infer<typeof updateCorrectionSchema>;
const { params, body } = req as unknown as UpdateCorrectionRequest;
try {
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(correctionId, suggested_value, req.log);
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(params.id, body.suggested_value, req.log);
res.status(200).json(updatedCorrection);
} catch (error) {
next(error);
}
});
router.put('/recipes/:id/status', validateRequest(updateRecipeStatusSchema), async (req, res, next: NextFunction) => {
const recipeId = req.params.id as unknown as number;
const { status } = req.body;
router.put('/recipes/:id/status', validateRequest(updateRecipeStatusSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateRecipeStatusRequest = z.infer<typeof updateRecipeStatusSchema>;
const { params, body } = req as unknown as UpdateRecipeStatusRequest;
try {
const updatedRecipe = await db.adminRepo.updateRecipeStatus(recipeId, status, req.log); // This is still a standalone function in admin.db.ts
const updatedRecipe = await db.adminRepo.updateRecipeStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedRecipe);
} catch (error) {
next(error); // Pass all errors to the central error handler
}
});
router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('id')), upload.single('logoImage'), requireFileUpload('logoImage'), async (req, res, next: NextFunction) => {
const brandId = req.params.id as unknown as number;
router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('id')), upload.single('logoImage'), requireFileUpload('logoImage'), async (req: Request, res: Response, next: NextFunction) => {
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try {
// Although requireFileUpload middleware should ensure the file exists,
// this check satisfies TypeScript and adds robustness.
@@ -191,9 +201,9 @@ router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('id')), upl
throw new ValidationError([], 'Logo image file is missing.');
}
const logoUrl = `/assets/${req.file.filename}`;
await db.adminRepo.updateBrandLogo(brandId, logoUrl, req.log);
await db.adminRepo.updateBrandLogo(params.id, logoUrl, req.log);
logger.info({ brandId, logoUrl }, `Brand logo updated for brand ID: ${brandId}`);
logger.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`);
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
} catch (error) {
next(error);
@@ -212,13 +222,12 @@ router.get('/unmatched-items', async (req, res, next: NextFunction) => {
/**
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
*/
router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipeId')), async (req, res, next: NextFunction) => {
router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipeId')), async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile;
const recipeId = req.params.recipeId as unknown as number;
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try {
// The isAdmin flag bypasses the ownership check in the repository method.
await db.recipeRepo.deleteRecipe(recipeId, adminUser.user_id, true, req.log);
await db.recipeRepo.deleteRecipe(params.recipeId, adminUser.user_id, true, req.log);
res.status(204).send();
} catch (error: unknown) {
next(error);
@@ -228,21 +237,21 @@ router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipe
/**
* DELETE /api/admin/flyers/:flyerId - Admin endpoint to delete a flyer and its items.
*/
router.delete('/flyers/:flyerId', validateRequest(numericIdParamSchema('flyerId')), async (req, res, next: NextFunction) => {
const flyerId = req.params.flyerId as unknown as number;
router.delete('/flyers/:flyerId', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try {
await db.flyerRepo.deleteFlyer(flyerId, req.log);
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
res.status(204).send();
} catch (error: unknown) {
next(error);
}
});
router.put('/comments/:id/status', validateRequest(updateCommentStatusSchema), async (req, res, next: NextFunction) => {
const commentId = req.params.id as unknown as number;
const { status } = req.body;
router.put('/comments/:id/status', validateRequest(updateCommentStatusSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateCommentStatusRequest = z.infer<typeof updateCommentStatusSchema>;
const { params, body } = req as unknown as UpdateCommentStatusRequest;
try {
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(commentId, status, req.log); // This is still a standalone function in admin.db.ts
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedComment);
} catch (error: unknown) {
next(error);
@@ -258,44 +267,47 @@ router.get('/users', async (req, res, next: NextFunction) => {
}
});
router.get('/activity-log', validateRequest(activityLogSchema), async (req, res, next: NextFunction) => {
const { limit, offset } = req.query as unknown as { limit: number; offset: number };
router.get('/activity-log', validateRequest(activityLogSchema), async (req: Request, res: Response, next: NextFunction) => {
type ActivityLogRequest = z.infer<typeof activityLogSchema>;
const { query } = req as unknown as ActivityLogRequest;
try {
const logs = await db.adminRepo.getActivityLog(limit, offset, req.log);
const logs = await db.adminRepo.getActivityLog(query.limit, query.offset, req.log);
res.json(logs);
} catch (error) {
next(error);
}
});
router.get('/users/:id', validateRequest(z.object({ params: z.object({ id: z.string().uuid() }) })), async (req, res, next: NextFunction) => {
router.get('/users/:id', validateRequest(uuidParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
const { params } = req as z.infer<ReturnType<typeof uuidParamSchema>>;
try {
const user = await db.userRepo.findUserProfileById(req.params.id, req.log);
const user = await db.userRepo.findUserProfileById(params.id, req.log);
res.json(user);
} catch (error) {
next(error);
}
});
router.put('/users/:id', validateRequest(updateUserRoleSchema), async (req, res, next: NextFunction) => {
const { role } = req.body;
router.put('/users/:id', validateRequest(updateUserRoleSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateUserRoleRequest = z.infer<typeof updateUserRoleSchema>;
const { params, body } = req as unknown as UpdateUserRoleRequest;
try {
const updatedUser = await db.adminRepo.updateUserRole(req.params.id, role, req.log);
const updatedUser = await db.adminRepo.updateUserRole(params.id, body.role, req.log);
res.json(updatedUser);
} catch (error) {
logger.error({ error }, `Error updating user ${req.params.id}:`);
logger.error({ error }, `Error updating user ${params.id}:`);
next(error);
}
});
router.delete('/users/:id', validateRequest(z.object({ params: z.object({ id: z.string().uuid() }) })), async (req, res, next: NextFunction) => {
router.delete('/users/:id', validateRequest(uuidParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile;
const { params } = req as z.infer<ReturnType<typeof uuidParamSchema>>;
try {
if (adminUser.user.user_id === req.params.id) {
if (adminUser.user.user_id === params.id) {
throw new ValidationError([], 'Admins cannot delete their own account.');
}
await db.userRepo.deleteUserById(req.params.id, req.log);
await db.userRepo.deleteUserById(params.id, req.log);
res.status(204).send();
} catch (error) {
next(error);
@@ -306,7 +318,7 @@ router.delete('/users/:id', validateRequest(z.object({ params: z.object({ id: z.
* 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) => {
router.post('/trigger/daily-deal-check', async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for daily deal check received from user: ${adminUser.user_id}`);
@@ -325,7 +337,7 @@ router.post('/trigger/daily-deal-check', async (req, res, next: NextFunction) =>
* 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) => {
router.post('/trigger/analytics-report', async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for analytics report generation received from user: ${adminUser.user_id}`);
@@ -347,16 +359,15 @@ router.post('/trigger/analytics-report', async (req, res, next: NextFunction) =>
* 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', validateRequest(numericIdParamSchema('flyerId')), async (req, res, next: NextFunction) => {
router.post('/flyers/:flyerId/cleanup', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile;
const flyerId = req.params.flyerId as unknown as number;
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${flyerId}`);
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${params.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.` });
await cleanupQueue.add('cleanup-flyer-files', { flyerId: params.flyerId });
res.status(202).json({ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` });
} catch (error) {
next(error);
}
@@ -366,7 +377,7 @@ router.post('/flyers/:flyerId/cleanup', validateRequest(numericIdParamSchema('fl
* 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) => {
router.post('/trigger/failing-job', async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for a failing job received from user: ${adminUser.user_id}`);
@@ -383,7 +394,7 @@ router.post('/trigger/failing-job', async (req, res, next: NextFunction) => {
* 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) => {
router.post('/system/clear-geocode-cache', async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for geocode cache clear received from user: ${adminUser.user_id}`);
@@ -400,7 +411,7 @@ router.post('/system/clear-geocode-cache', async (req, res, next: NextFunction)
* 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) => {
router.get('/workers/status', async (req: Request, res: Response) => {
const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker ];
const workerStatuses = await Promise.all(
@@ -419,7 +430,7 @@ router.get('/workers/status', async (req, res) => {
* 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) => {
router.get('/queues/status', async (req: Request, res: Response, next: NextFunction) => {
try {
const queues = [flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue];
@@ -440,9 +451,9 @@ router.get('/queues/status', async (req, res, next: NextFunction) => {
/**
* 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;
router.post('/jobs/:queueName/:jobId/retry', validateRequest(jobRetrySchema), async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile;
const { params: { queueName, jobId } } = req as unknown as z.infer<typeof jobRetrySchema>;
const queueMap: { [key: string]: Queue } = {
'flyer-processing': flyerQueue,
@@ -476,7 +487,7 @@ router.post('/jobs/:queueName/:jobId/retry', async (req, res, next: NextFunction
/**
* POST /api/admin/trigger/weekly-analytics - Manually trigger the weekly analytics report job.
*/
router.post('/trigger/weekly-analytics', async (req, res, next: NextFunction) => {
router.post('/trigger/weekly-analytics', async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for weekly analytics report received from user: ${adminUser.user_id}`);

View File

@@ -146,6 +146,13 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
expect(response.status).toBe(404);
expect(response.body.message).toBe('User with ID non-existent not found.');
});
it('should return 400 for an invalid role', async () => {
const response = await supertest(app)
.put('/api/admin/users/user-to-update')
.send({ role: 'super-admin' });
expect(response.status).toBe(400);
});
});
describe('DELETE /users/:id', () => {

View File

@@ -95,6 +95,15 @@ describe('AI Routes (/api/ai)', () => {
expect(response.body.message).toBe('A flyer file (PDF or image) is required.');
});
it('should return 400 if checksum is missing', async () => {
const response = await supertest(app)
.post('/api/ai/upload-and-process')
.attach('flyerFile', imagePath);
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('File checksum is required.');
});
it('should return 409 if flyer checksum already exists', async () => {
vi.mocked(db.flyerRepo.findFlyerByChecksum).mockResolvedValue(createMockFlyer({ flyer_id: 99 }));
@@ -195,6 +204,13 @@ describe('AI Routes (/api/ai)', () => {
expect(response.status).toBe(200);
expect(response.body.state).toBe('completed');
});
it('should return 400 for an invalid job ID format', async () => {
// Assuming job IDs should not be empty, for example.
const response = await supertest(app).get('/api/ai/jobs/ /status'); // Send an invalid ID
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('A valid Job ID is required.');
});
});
describe('POST /flyers/process (Legacy)', () => {
@@ -384,6 +400,15 @@ describe('AI Routes (/api/ai)', () => {
.field('extractionType', 'store_name');
expect(response.status).toBe(400);
});
it('should return 400 if cropArea or extractionType is missing', async () => {
const response = await supertest(app)
.post('/api/ai/rescan-area')
.attach('image', imagePath)
.field('extractionType', 'store_name'); // Missing cropArea
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('cropArea must be a valid JSON string');
});
});
describe('POST /extract-address', () => {

View File

@@ -192,7 +192,8 @@ router.post('/upload-and-process', optionalAuth, uploadToDisk.single('flyerFile'
* NEW ENDPOINT: Checks the status of a background job.
*/
router.get('/jobs/:jobId/status', validateRequest(jobIdParamSchema), async (req, res, next: NextFunction) => {
const { jobId } = req.params;
type JobIdRequest = z.infer<typeof jobIdParamSchema>;
const { params: { jobId } } = req as unknown as JobIdRequest;
try {
const job = await flyerQueue.getJob(jobId);
if (!job) {

View File

@@ -240,6 +240,24 @@ describe('Auth Routes (/api/auth)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB connection lost'); // The errorHandler will forward the message
});
it('should return 400 for an invalid email format', async () => {
const response = await supertest(app)
.post('/api/auth/register')
.send({ email: 'not-an-email', password: strongPassword });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('A valid email is required.');
});
it('should return 400 for a password that is too short', async () => {
const response = await supertest(app)
.post('/api/auth/register')
.send({ email: newUserEmail, password: 'short' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Password must be at least 8 characters long.');
});
});
describe('POST /login', () => {
@@ -393,6 +411,15 @@ describe('Auth Routes (/api/auth)', () => {
// Assert: The route should not fail even if the email does.
expect(response.status).toBe(200);
});
it('should return 400 for an invalid email format', async () => {
const response = await supertest(app)
.post('/api/auth/forgot-password')
.send({ email: 'invalid-email' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('A valid email is required.');
});
});
describe('POST /reset-password', () => {
@@ -431,6 +458,15 @@ describe('Auth Routes (/api/auth)', () => {
const response = await supertest(app).post('/api/auth/reset-password').send({ token: 'valid-token', newPassword: 'weak' });
expect(response.status).toBe(400);
});
it('should return 400 if token is missing', async () => {
const response = await supertest(app)
.post('/api/auth/reset-password')
.send({ newPassword: 'a-Very-Strong-Password-789!' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Token is required.');
});
});
describe('POST /refresh-token', () => {

View File

@@ -90,9 +90,10 @@ const resetPasswordSchema = z.object({
// --- Authentication Routes ---
// Registration Route
router.post('/register', validateRequest(registerSchema), async (req, res, next) => {
const { email, password, full_name, avatar_url } = req.body;
router.post('/register', validateRequest(registerSchema), async (req: Request, res: Response, next: NextFunction) => {
type RegisterRequest = z.infer<typeof registerSchema>;
const { body: { email, password, full_name, avatar_url } } = req as unknown as RegisterRequest;
try {
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
@@ -190,9 +191,10 @@ router.post('/login', (req: Request, res: Response, next: NextFunction) => {
});
// Route to request a password reset
router.post('/forgot-password', forgotPasswordLimiter, validateRequest(forgotPasswordSchema), async (req, res, next) => {
const { email } = req.body;
router.post('/forgot-password', forgotPasswordLimiter, validateRequest(forgotPasswordSchema), async (req: Request, res: Response, next: NextFunction) => {
type ForgotPasswordRequest = z.infer<typeof forgotPasswordSchema>;
const { body: { email } } = req as unknown as ForgotPasswordRequest;
try {
req.log.debug(`[API /forgot-password] Received request for email: ${email}`);
const user = await userRepo.findUserByEmail(email, req.log);
@@ -231,9 +233,10 @@ router.post('/forgot-password', forgotPasswordLimiter, validateRequest(forgotPas
});
// Route to reset the password using a token
router.post('/reset-password', resetPasswordLimiter, validateRequest(resetPasswordSchema), async (req, res, next) => {
const { token, newPassword } = req.body;
router.post('/reset-password', resetPasswordLimiter, validateRequest(resetPasswordSchema), async (req: Request, res: Response, next: NextFunction) => {
type ResetPasswordRequest = z.infer<typeof resetPasswordSchema>;
const { body: { token, newPassword } } = req as unknown as ResetPasswordRequest;
try {
const validTokens = await userRepo.getValidResetTokens(req.log);
let tokenRecord;

View File

@@ -114,6 +114,20 @@ describe('Budget Routes (/api/budgets)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for invalid budget data', async () => {
const invalidData = {
name: '', // empty name
amount_cents: -100, // negative amount
period: 'yearly', // invalid period
start_date: 'not-a-date', // invalid date
};
const response = await supertest(app).post('/api/budgets').send(invalidData);
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(4);
});
});
describe('PUT /:id', () => {
@@ -143,6 +157,12 @@ describe('Budget Routes (/api/budgets)', () => {
expect(response.status).toBe(500); // The custom handler will now be used
expect(response.body.message).toBe('DB Error');
});
it('should return 400 if no update fields are provided', async () => {
const response = await supertest(app).put('/api/budgets/1').send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('At least one field to update must be provided.');
});
});
describe('DELETE /:id', () => {
@@ -192,5 +212,11 @@ describe('Budget Routes (/api/budgets)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for invalid date formats', async () => {
const response = await supertest(app).get('/api/budgets/spending-analysis?startDate=2024/01/01&endDate=invalid');
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(2);
});
});
});

View File

@@ -1,5 +1,5 @@
// src/routes/budget.ts
import express, { NextFunction } from 'express';
import express, { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import passport from './passport.routes';
import { budgetRepo } from '../services/db/index.db';
@@ -20,7 +20,7 @@ const createBudgetSchema = z.object({
body: z.object({
name: z.string().min(1, 'Budget name is required.'),
amount_cents: z.number().int().positive('Amount must be a positive integer.'),
period: z.enum(['weekly', 'monthly', 'yearly']),
period: z.enum(['weekly', 'monthly']),
start_date: z.string().date('Start date must be a valid date in YYYY-MM-DD format.'),
}),
});
@@ -44,13 +44,13 @@ router.use(passport.authenticate('jwt', { session: false }));
/**
* GET /api/budgets - Get all budgets for the authenticated user.
*/
router.get('/', async (req, res, next: NextFunction) => {
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as UserProfile;
try {
const budgets = await budgetRepo.getBudgetsForUser(user.user_id, req.log);
res.json(budgets);
} catch (error) {
req.log.error({ err: error, userId: user.user_id }, 'Error fetching budgets');
req.log.error({ error, userId: user.user_id }, 'Error fetching budgets');
next(error);
}
});
@@ -58,13 +58,15 @@ router.get('/', async (req, res, next: NextFunction) => {
/**
* POST /api/budgets - Create a new budget for the authenticated user.
*/
router.post('/', validateRequest(createBudgetSchema), async (req, res, next: NextFunction) => {
router.post('/', validateRequest(createBudgetSchema), async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as UserProfile;
type CreateBudgetRequest = z.infer<typeof createBudgetSchema>;
const { body } = req as unknown as CreateBudgetRequest;
try {
const newBudget = await budgetRepo.createBudget(user.user_id, req.body, req.log);
const newBudget = await budgetRepo.createBudget(user.user_id, body, req.log);
res.status(201).json(newBudget);
} catch (error: unknown) {
req.log.error({ err: error, userId: user.user_id, body: req.body }, 'Error creating budget');
req.log.error({ error, userId: user.user_id, body }, 'Error creating budget');
next(error);
}
});
@@ -72,14 +74,15 @@ router.post('/', validateRequest(createBudgetSchema), async (req, res, next: Nex
/**
* PUT /api/budgets/:id - Update an existing budget.
*/
router.put('/:id', validateRequest(updateBudgetSchema), async (req, res, next: NextFunction) => {
router.put('/:id', validateRequest(updateBudgetSchema), async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as UserProfile;
const budgetId = req.params.id as unknown as number;
type UpdateBudgetRequest = z.infer<typeof updateBudgetSchema>;
const { params, body } = req as unknown as UpdateBudgetRequest;
try {
const updatedBudget = await budgetRepo.updateBudget(budgetId, user.user_id, req.body, req.log);
const updatedBudget = await budgetRepo.updateBudget(params.id, user.user_id, body, req.log);
res.json(updatedBudget);
} catch (error: unknown) {
req.log.error({ err: error, userId: user.user_id, budgetId }, 'Error updating budget');
req.log.error({ error, userId: user.user_id, budgetId: params.id }, 'Error updating budget');
next(error);
}
});
@@ -87,14 +90,15 @@ router.put('/:id', validateRequest(updateBudgetSchema), async (req, res, next: N
/**
* DELETE /api/budgets/:id - Delete a budget.
*/
router.delete('/:id', validateRequest(budgetIdParamSchema), async (req, res, next: NextFunction) => {
router.delete('/:id', validateRequest(budgetIdParamSchema), async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as UserProfile;
const budgetId = req.params.id as unknown as number;
type DeleteBudgetRequest = z.infer<typeof budgetIdParamSchema>;
const { params } = req as unknown as DeleteBudgetRequest;
try {
await budgetRepo.deleteBudget(budgetId, user.user_id, req.log);
await budgetRepo.deleteBudget(params.id, user.user_id, req.log);
res.status(204).send(); // No Content
} catch (error: unknown) {
req.log.error({ err: error, userId: user.user_id, budgetId }, 'Error deleting budget');
req.log.error({ error, userId: user.user_id, budgetId: params.id }, 'Error deleting budget');
next(error);
}
});
@@ -103,15 +107,16 @@ router.delete('/:id', validateRequest(budgetIdParamSchema), async (req, res, nex
* GET /api/spending-analysis - Get spending breakdown by category for a date range.
* Query params: startDate (YYYY-MM-DD), endDate (YYYY-MM-DD)
*/
router.get('/spending-analysis', validateRequest(spendingAnalysisSchema), async (req, res, next: NextFunction) => {
router.get('/spending-analysis', validateRequest(spendingAnalysisSchema), async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as UserProfile;
const { startDate, endDate } = req.query;
type SpendingAnalysisRequest = z.infer<typeof spendingAnalysisSchema>;
const { query: { startDate, endDate } } = req as unknown as SpendingAnalysisRequest;
try {
const spendingData = await budgetRepo.getSpendingByCategory(user.user_id, startDate as string, endDate as string, req.log);
const spendingData = await budgetRepo.getSpendingByCategory(user.user_id, startDate, endDate, req.log);
res.json(spendingData);
} catch (error) {
req.log.error({ err: error, userId: user.user_id, startDate, endDate }, 'Error fetching spending analysis');
req.log.error({ error, userId: user.user_id, startDate, endDate }, 'Error fetching spending analysis');
next(error);
}
});

View File

@@ -1,11 +1,19 @@
// src/routes/deals.routes.ts
import express, { type Request, type Response, type NextFunction } from 'express';
import { z } from 'zod';
import passport from './passport.routes';
import { dealsRepo } from '../services/db/deals.db';
import type { UserProfile } from '../types';
import { validateRequest } from '../middleware/validation.middleware';
const router = express.Router();
// --- Zod Schemas for Deals Routes (as per ADR-003) ---
const bestWatchedPricesSchema = z.object({
// No params, query, or body expected for this route currently.
});
// --- Middleware for all deal routes ---
// Per ADR-002, all routes in this file require an authenticated user.
@@ -17,7 +25,7 @@ router.use(passport.authenticate('jwt', { session: false }));
* @description Fetches the best current sale price for each of the authenticated user's watched items.
* @access Private
*/
router.get('/best-watched-prices', async (req: Request, res: Response, next: NextFunction) => {
router.get('/best-watched-prices', validateRequest(bestWatchedPricesSchema), async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as UserProfile;
try {
// The controller logic is simple enough to be handled directly in the route,
@@ -26,7 +34,7 @@ router.get('/best-watched-prices', async (req: Request, res: Response, next: Nex
req.log.info({ dealCount: deals.length }, 'Successfully fetched best watched item deals.');
res.status(200).json(deals);
} catch (error) {
req.log.error({ err: error }, 'Error fetching best watched item deals.');
req.log.error({ error }, 'Error fetching best watched item deals.');
next(error); // Pass errors to the global error handler
}
});

View File

@@ -32,6 +32,13 @@ import * as db from '../services/db/index.db';
// Create the Express app
const app = express();
app.use(express.json());
// Add a middleware to inject a mock req.log object for tests
app.use((req, res, next) => {
req.log = { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() } as any;
next();
});
// Mount the router under its designated base path
app.use('/api/flyers', flyerRouter);
app.use(errorHandler);
@@ -64,6 +71,13 @@ describe('Flyer Routes (/api/flyers)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for invalid query parameters', async () => {
const response = await supertest(app).get('/api/flyers?limit=abc&offset=-5');
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(2);
});
});
describe('GET /:id', () => {

View File

@@ -41,10 +41,11 @@ const trackItemSchema = z.object({
/**
* GET /api/flyers - Get a paginated list of all flyers.
*/
router.get('/', validateRequest(getFlyersSchema), async (req, res, next: NextFunction) => {
type GetFlyersRequest = z.infer<typeof getFlyersSchema>;
router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
const { query } = req as unknown as GetFlyersRequest;
try {
const { limit, offset } = req.query as unknown as { limit: number; offset: number };
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
const flyers = await db.flyerRepo.getFlyers(req.log, query.limit, query.offset);
res.json(flyers);
} catch (error) {
req.log.error({ error }, 'Error fetching flyers in /api/flyers:');
@@ -55,10 +56,11 @@ router.get('/', validateRequest(getFlyersSchema), async (req, res, next: NextFun
/**
* GET /api/flyers/:id - Get a single flyer by its ID.
*/
router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next: NextFunction) => {
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
const { params } = req as unknown as GetFlyerByIdRequest;
try {
const flyerId = req.params.id as unknown as number;
const flyer = await db.flyerRepo.getFlyerById(flyerId, req.log);
const flyer = await db.flyerRepo.getFlyerById(params.id, req.log);
res.json(flyer);
} catch (error) {
next(error);
@@ -68,10 +70,10 @@ router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next: N
/**
* GET /api/flyers/:id/items - Get all items for a specific flyer.
*/
router.get('/:id/items', validateRequest(flyerIdParamSchema), async (req, res, next: NextFunction) => {
router.get('/:id/items', validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
const { params } = req as unknown as GetFlyerByIdRequest;
try {
const flyerId = req.params.id as unknown as number;
const items = await db.flyerRepo.getFlyerItems(flyerId, req.log);
const items = await db.flyerRepo.getFlyerItems(params.id, req.log);
res.json(items);
} catch (error) {
req.log.error({ error }, 'Error fetching flyer items in /api/flyers/:id/items:');
@@ -82,10 +84,11 @@ router.get('/:id/items', validateRequest(flyerIdParamSchema), async (req, res, n
/**
* POST /api/flyers/items/batch-fetch - Get all items for multiple flyers at once.
*/
router.post('/items/batch-fetch', validateRequest(batchFetchSchema), async (req, res, next: NextFunction) => {
const { flyerIds } = req.body as { flyerIds: number[] };
type BatchFetchRequest = z.infer<typeof batchFetchSchema>;
router.post('/items/batch-fetch', validateRequest(batchFetchSchema), async (req, res, next): Promise<void> => {
const { body } = req as unknown as BatchFetchRequest;
try {
const items = await db.flyerRepo.getFlyerItemsForFlyers(flyerIds, req.log);
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
res.json(items);
} catch (error) {
next(error);
@@ -95,11 +98,12 @@ router.post('/items/batch-fetch', validateRequest(batchFetchSchema), async (req,
/**
* POST /api/flyers/items/batch-count - Get the total number of items for multiple flyers.
*/
router.post('/items/batch-count', validateRequest(batchFetchSchema.partial()), async (req, res, next: NextFunction) => {
const { flyerIds } = req.body as { flyerIds?: number[] };
type BatchCountRequest = z.infer<typeof batchFetchSchema>;
router.post('/items/batch-count', validateRequest(batchFetchSchema.partial()), async (req, res, next): Promise<void> => {
const { body } = req as unknown as BatchCountRequest;
try {
// The DB function handles an empty array, so we can simplify.
const count = await db.flyerRepo.countFlyerItemsForFlyers(flyerIds ?? [], req.log);
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds ?? [], req.log);
res.json({ count });
} catch (error) {
next(error);
@@ -109,10 +113,10 @@ router.post('/items/batch-count', validateRequest(batchFetchSchema.partial()), a
/**
* POST /api/flyers/items/:itemId/track - Tracks a user interaction with a flyer item.
*/
router.post('/items/:itemId/track', validateRequest(trackItemSchema), (req: Request, res: Response) => {
const itemId = req.params.itemId as unknown as number;
const { type } = req.body as { type: 'view' | 'click' };
db.flyerRepo.trackFlyerItemInteraction(itemId, type, req.log);
type TrackItemRequest = z.infer<typeof trackItemSchema>;
router.post('/items/:itemId/track', validateRequest(trackItemSchema), (req, res): void => {
const { params, body } = req as unknown as TrackItemRequest;
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log);
res.status(202).send();
});

View File

@@ -184,6 +184,15 @@ describe('Gamification Routes (/api/achievements)', () => {
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for an invalid userId or achievementName', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => { req.user = mockAdminProfile; next(); });
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
const response = await supertest(app).post('/api/achievements/award').send({ userId: '', achievementName: '' });
expect(response.status).toBe(400);
expect(response.body.errors).toHaveLength(2);
});
it('should return 400 if awarding an achievement to a non-existent user', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
@@ -216,5 +225,12 @@ describe('Gamification Routes (/api/achievements)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for an invalid limit parameter', async () => {
const response = await supertest(app).get('/api/achievements/leaderboard?limit=100');
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toContain('less than or equal to 50');
});
});
});

View File

@@ -34,10 +34,10 @@ const awardAchievementSchema = z.object({
*/
router.get('/', async (req, res, next: NextFunction) => {
try {
const achievements = await gamificationRepo.getAllAchievements();
const achievements = await gamificationRepo.getAllAchievements(req.log);
res.json(achievements);
} catch (error) {
logger.error('Error fetching all achievements in /api/achievements:', { error });
logger.error({ error }, 'Error fetching all achievements in /api/achievements:');
next(error);
}
});
@@ -46,13 +46,14 @@ router.get('/', async (req, res, next: NextFunction) => {
* GET /api/achievements/leaderboard - Get the top users by points.
* This is a public endpoint.
*/
router.get('/leaderboard', validateRequest(leaderboardSchema), async (req, res, next: NextFunction) => {
try { // The `limit` is coerced to a number by Zod, but TypeScript doesn't know that yet.
const { limit } = req.query as unknown as { limit: number };
const leaderboard = await gamificationRepo.getLeaderboard(limit);
type LeaderboardRequest = z.infer<typeof leaderboardSchema>;
router.get('/leaderboard', validateRequest(leaderboardSchema), async (req, res, next: NextFunction): Promise<void> => {
const { query } = req as unknown as LeaderboardRequest;
try {
const leaderboard = await gamificationRepo.getLeaderboard(query.limit, req.log);
res.json(leaderboard);
} catch (error) {
logger.error('Error fetching leaderboard:', { error });
logger.error({ error }, 'Error fetching leaderboard:');
next(error);
}
});
@@ -66,13 +67,13 @@ router.get('/leaderboard', validateRequest(leaderboardSchema), async (req, res,
router.get(
'/me',
passport.authenticate('jwt', { session: false }),
async (req, res, next: NextFunction) => {
async (req, res, next: NextFunction): Promise<void> => {
const user = req.user as UserProfile;
try {
const userAchievements = await gamificationRepo.getUserAchievements(user.user_id);
const userAchievements = await gamificationRepo.getUserAchievements(user.user_id, req.log);
res.json(userAchievements);
} catch (error) {
logger.error('Error fetching user achievements:', { error, userId: user.user_id });
logger.error({ error, userId: user.user_id }, 'Error fetching user achievements:');
next(error);
}
}
@@ -88,19 +89,20 @@ adminGamificationRouter.use(passport.authenticate('jwt', { session: false }), is
* This is an admin-only endpoint.
*/
adminGamificationRouter.post(
'/award',
validateRequest(awardAchievementSchema),
async (req, res, next: NextFunction) => {
const { userId, achievementName } = req.body;
'/award', validateRequest(awardAchievementSchema),
async (req, res, next: NextFunction): Promise<void> => {
// Infer type and cast request object as per ADR-003
type AwardAchievementRequest = z.infer<typeof awardAchievementSchema>;
const { body } = req as unknown as AwardAchievementRequest;
try {
await gamificationRepo.awardAchievement(userId, achievementName);
res.status(200).json({ message: `Successfully awarded '${achievementName}' to user ${userId}.` });
await gamificationRepo.awardAchievement(body.userId, body.achievementName, req.log);
res.status(200).json({ message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.` });
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
res.status(400).json({ message: error.message });
return;
}
logger.error('Error awarding achievement via admin endpoint:', { error, userId, achievementName });
logger.error({ error, userId: body.userId, achievementName: body.achievementName }, 'Error awarding achievement via admin endpoint:');
next(error);
}
}

View File

@@ -1,18 +1,32 @@
// src/routes/health.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { checkTablesExist, getPoolStatus } from '../services/db/connection.db';
import { logger } from '../services/logger.server';
import { connection as redisConnection } from '../services/queueService.server';
import fs from 'node:fs/promises';
import { getSimpleWeekAndYear } from '../utils/dateUtils';
import { validateRequest } from '../middleware/validation.middleware';
const router = Router();
router.get('/ping', (_req: Request, res: Response) => {
// --- Zod Schemas for Health Routes (as per ADR-003) ---
// These routes do not expect any input, so we define empty schemas
// to maintain a consistent validation pattern across the application.
const emptySchema = z.object({});
/**
* GET /api/health/ping - A simple endpoint to check if the server is responsive.
*/
router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response) => {
res.status(200).send('pong');
});
router.get('/db-schema', async (req, res, next: NextFunction) => {
/**
* GET /api/health/db-schema - Checks if all essential database tables exist.
* This is a critical check to ensure the database schema is correctly set up.
*/
router.get('/db-schema', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores'];
const missingTables = await checkTablesExist(requiredTables);
@@ -23,23 +37,31 @@ router.get('/db-schema', async (req, res, next: NextFunction) => {
}
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
} catch (error: unknown) {
logger.error('Error during DB schema check:', { error: error instanceof Error ? error.message : error });
logger.error({ error: error instanceof Error ? error.message : error }, 'Error during DB schema check:');
next(error);
}
});
router.get('/storage', async (req, res, next: NextFunction) => {
/**
* GET /api/health/storage - Verifies that the application's file storage path is accessible and writable.
* This is important for features like file uploads.
*/
router.get('/storage', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets';
try {
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
return res.status(200).json({ success: true, message: `Storage directory '${storagePath}' is accessible and writable.` });
} catch (error: unknown) {
logger.error(`Storage check failed for path: ${storagePath}`, { error: error instanceof Error ? error.message : error });
logger.error({ error: error instanceof Error ? error.message : error }, `Storage check failed for path: ${storagePath}`);
next(new Error(`Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.`));
}
});
router.get('/db-pool', (req: Request, res: Response, next: NextFunction) => {
/**
* GET /api/health/db-pool - Checks the status of the database connection pool.
* This helps diagnose issues related to database connection saturation.
*/
router.get('/db-pool', validateRequest(emptySchema), (req: Request, res: Response, next: NextFunction) => {
try {
const status = getPoolStatus();
const isHealthy = status.waitingCount < 5;
@@ -52,12 +74,16 @@ router.get('/db-pool', (req: Request, res: Response, next: NextFunction) => {
return res.status(500).json({ success: false, message: `Pool may be under stress. ${message}` });
}
} catch (error: unknown) {
logger.error('Error during DB pool health check:', { error: error instanceof Error ? error.message : error });
logger.error({ error: error instanceof Error ? error.message : error }, 'Error during DB pool health check:');
next(error);
}
});
router.get('/time', (req: Request, res: Response) => {
/**
* GET /api/health/time - Returns the server's current time, year, and week number.
* Useful for verifying time synchronization and for features dependent on week numbers.
*/
router.get('/time', validateRequest(emptySchema), (req: Request, res: Response) => {
const now = new Date();
const { year, week } = getSimpleWeekAndYear(now);
res.json({
@@ -70,7 +96,7 @@ router.get('/time', (req: Request, res: Response) => {
/**
* GET /api/health/redis - Checks the health of the Redis connection.
*/
router.get('/redis', async (req: Request, res: Response, next: NextFunction) => {
router.get('/redis', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const reply = await redisConnection.ping();
if (reply === 'PONG') {

View File

@@ -28,6 +28,13 @@ import * as db from '../services/db/index.db';
// Create the Express app
const app = express();
app.use(express.json());
// Add a middleware to inject a mock req.log object for tests
app.use((req, res, next) => {
req.log = { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() } as any;
next();
});
// Mount the router under its designated base path
app.use('/api/personalization', personalizationRouter);
app.use(errorHandler);

View File

@@ -1,13 +1,20 @@
// src/routes/personalization.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
const router = Router();
// --- Zod Schemas for Personalization Routes (as per ADR-003) ---
// These routes do not expect any input, so we define an empty schema
// to maintain a consistent validation pattern across the application.
const emptySchema = z.object({});
/**
* GET /api/personalization/master-items - Get the master list of all grocery items.
*/
router.get('/master-items', async (req: Request, res: Response, next: NextFunction) => {
router.get('/master-items', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const masterItems = await db.personalizationRepo.getAllMasterItems(req.log);
res.json(masterItems);
@@ -20,7 +27,7 @@ router.get('/master-items', async (req: Request, res: Response, next: NextFuncti
/**
* GET /api/personalization/dietary-restrictions - Get the master list of all dietary restrictions.
*/
router.get('/dietary-restrictions', async (req: Request, res: Response, next: NextFunction) => {
router.get('/dietary-restrictions', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const restrictions = await db.personalizationRepo.getDietaryRestrictions(req.log);
res.json(restrictions);
@@ -33,7 +40,7 @@ router.get('/dietary-restrictions', async (req: Request, res: Response, next: Ne
/**
* GET /api/personalization/appliances - Get the master list of all kitchen appliances.
*/
router.get('/appliances', async (req: Request, res: Response, next: NextFunction) => {
router.get('/appliances', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const appliances = await db.personalizationRepo.getAppliances(req.log);
res.json(appliances);

View File

@@ -34,8 +34,20 @@ describe('Price Routes (/api/price-history)', () => {
expect(response.body).toEqual([]);
});
// The zod validation middleware will catch invalid requests before they hit the handler.
// We test this behavior in `validation.middleware.test.ts`.
// Therefore, we don't need tests here for non-array bodies, empty arrays, etc.
it('should return 400 if masterItemIds is not an array', async () => {
const response = await supertest(app)
.post('/api/price-history')
.send({ masterItemIds: 'not-an-array' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('Expected array, received string');
});
it('should return 400 if masterItemIds is an empty array', async () => {
const response = await supertest(app)
.post('/api/price-history')
.send({ masterItemIds: [] });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('masterItemIds must be a non-empty array of positive integers.');
});
});
});

View File

@@ -14,12 +14,16 @@ const priceHistorySchema = z.object({
}),
});
// Infer the type from the schema for local use, as per ADR-003.
type PriceHistoryRequest = z.infer<typeof priceHistorySchema>;
/**
* POST /api/price-history - Fetches historical price data for a given list of master item IDs.
* This is a placeholder implementation.
*/
router.post('/', validateRequest(priceHistorySchema), async (req: Request, res: Response, next: NextFunction) => {
const { masterItemIds } = req.body;
// Cast 'req' to the inferred type for full type safety.
const { body: { masterItemIds } } = req as unknown as PriceHistoryRequest;
req.log.info({ itemCount: masterItemIds.length }, '[API /price-history] Received request for historical price data.');
res.status(200).json([]);
});

View File

@@ -31,6 +31,13 @@ import * as db from '../services/db/index.db';
// Create the Express app
const app = express();
app.use(express.json());
// Add a middleware to inject a mock req.log object for tests
app.use((req, res, next) => {
req.log = { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() } as any;
next();
});
// Mount the router under its designated base path
app.use('/api/recipes', recipeRouter);
app.use(errorHandler);
@@ -64,6 +71,12 @@ describe('Recipe Routes (/api/recipes)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for an invalid minPercentage', async () => {
const response = await supertest(app).get('/api/recipes/by-sale-percentage?minPercentage=101');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('less than or equal to 100');
});
});
describe('GET /by-sale-ingredients', () => {
@@ -86,6 +99,12 @@ describe('Recipe Routes (/api/recipes)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for an invalid minIngredients', async () => {
const response = await supertest(app).get('/api/recipes/by-sale-ingredients?minIngredients=abc');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('Expected number, received nan');
});
});
describe('GET /by-ingredient-and-tag', () => {
@@ -106,6 +125,12 @@ describe('Recipe Routes (/api/recipes)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 if required query parameters are missing', async () => {
const response = await supertest(app).get('/api/recipes/by-ingredient-and-tag?ingredient=chicken');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('"tag" is required');
});
});
describe('GET /:recipeId/comments', () => {
@@ -132,6 +157,12 @@ describe('Recipe Routes (/api/recipes)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for an invalid recipeId', async () => {
const response = await supertest(app).get('/api/recipes/abc/comments');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('Expected number, received nan');
});
});
describe('GET /:recipeId', () => {
@@ -159,5 +190,11 @@ describe('Recipe Routes (/api/recipes)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for an invalid recipeId', async () => {
const response = await supertest(app).get('/api/recipes/abc');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toContain('Expected number, received nan');
});
});
});

View File

@@ -36,10 +36,11 @@ const recipeIdParamsSchema = z.object({
/**
* GET /api/recipes/by-sale-percentage - Get recipes based on the percentage of their ingredients on sale.
*/
router.get('/by-sale-percentage', validateRequest(bySalePercentageSchema), async (req: Request, res: Response, next: NextFunction) => {
try { // The `minPercentage` is coerced to a number by Zod, but TypeScript doesn't know that yet.
const { minPercentage } = req.query as unknown as { minPercentage: number };
const recipes = await db.recipeRepo.getRecipesBySalePercentage(minPercentage, req.log);
type BySalePercentageRequest = z.infer<typeof bySalePercentageSchema>;
router.get('/by-sale-percentage', validateRequest(bySalePercentageSchema), async (req, res, next) => {
try {
const { query } = req as unknown as BySalePercentageRequest;
const recipes = await db.recipeRepo.getRecipesBySalePercentage(query.minPercentage, req.log);
res.json(recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-percentage:');
@@ -50,10 +51,11 @@ router.get('/by-sale-percentage', validateRequest(bySalePercentageSchema), async
/**
* GET /api/recipes/by-sale-ingredients - Get recipes by the minimum number of sale ingredients.
*/
router.get('/by-sale-ingredients', validateRequest(bySaleIngredientsSchema), async (req: Request, res: Response, next: NextFunction) => {
try { // The `minIngredients` is coerced to a number by Zod, but TypeScript doesn't know that yet.
const { minIngredients } = req.query as unknown as { minIngredients: number };
const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(minIngredients, req.log);
type BySaleIngredientsRequest = z.infer<typeof bySaleIngredientsSchema>;
router.get('/by-sale-ingredients', validateRequest(bySaleIngredientsSchema), async (req, res, next) => {
try {
const { query } = req as unknown as BySaleIngredientsRequest;
const recipes = await db.recipeRepo.getRecipesByMinSaleIngredients(query.minIngredients, req.log);
res.json(recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-ingredients:');
@@ -64,10 +66,11 @@ router.get('/by-sale-ingredients', validateRequest(bySaleIngredientsSchema), asy
/**
* GET /api/recipes/by-ingredient-and-tag - Find recipes by a specific ingredient and tag.
*/
router.get('/by-ingredient-and-tag', validateRequest(byIngredientAndTagSchema), async (req: Request, res: Response, next: NextFunction) => {
try { // The query params are guaranteed to be strings by Zod, but TypeScript doesn't know that yet.
const { ingredient, tag } = req.query as { ingredient: string, tag: string };
const recipes = await db.recipeRepo.findRecipesByIngredientAndTag(ingredient, tag, req.log);
type ByIngredientAndTagRequest = z.infer<typeof byIngredientAndTagSchema>;
router.get('/by-ingredient-and-tag', validateRequest(byIngredientAndTagSchema), async (req, res, next) => {
try {
const { query } = req as unknown as ByIngredientAndTagRequest;
const recipes = await db.recipeRepo.findRecipesByIngredientAndTag(query.ingredient, query.tag, req.log);
res.json(recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-ingredient-and-tag:');
@@ -78,10 +81,11 @@ router.get('/by-ingredient-and-tag', validateRequest(byIngredientAndTagSchema),
/**
* GET /api/recipes/:recipeId/comments - Get all comments for a specific recipe.
*/
router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (req: Request, res: Response, next: NextFunction) => {
try { // The `recipeId` is coerced to a number by Zod, but TypeScript doesn't know that yet.
const { recipeId } = req.params as unknown as { recipeId: number };
const comments = await db.recipeRepo.getRecipeComments(recipeId, req.log);
type RecipeIdRequest = z.infer<typeof recipeIdParamsSchema>;
router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (req, res, next) => {
try {
const { params } = req as unknown as RecipeIdRequest;
const comments = await db.recipeRepo.getRecipeComments(params.recipeId, req.log);
res.json(comments);
} catch (error) {
req.log.error({ error }, `Error fetching comments for recipe ID ${req.params.recipeId}:`);
@@ -92,10 +96,10 @@ router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (
/**
* GET /api/recipes/:recipeId - Get a single recipe by its ID, including ingredients and tags.
*/
router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req: Request, res: Response, next: NextFunction) => {
try { // The `recipeId` is coerced to a number by Zod, but TypeScript doesn't know that yet.
const { recipeId } = req.params as unknown as { recipeId: number };
const recipe = await db.recipeRepo.getRecipeById(recipeId, req.log);
router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req, res, next) => {
try {
const { params } = req as unknown as RecipeIdRequest;
const recipe = await db.recipeRepo.getRecipeById(params.recipeId, req.log);
res.json(recipe);
} catch (error) {
req.log.error({ error }, `Error fetching recipe ID ${req.params.recipeId}:`);

View File

@@ -25,6 +25,13 @@ import * as db from '../services/db/index.db';
// Create the Express app
const app = express();
app.use(express.json());
// Add a middleware to inject a mock req.log object for tests
app.use((req, res, next) => {
req.log = { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() } as any;
next();
});
// Mount the router under its designated base path
app.use('/api/stats', statsRouter);
app.use(errorHandler);
@@ -54,5 +61,12 @@ describe('Stats Routes (/api/stats)', () => {
expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error');
});
it('should return 400 for invalid query parameters', async () => {
const response = await supertest(app).get('/api/stats/most-frequent-sales?days=0&limit=abc');
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBe(2);
});
});
});

View File

@@ -16,13 +16,16 @@ const mostFrequentSalesSchema = z.object({
}),
});
// Infer the type from the schema for local use, as per ADR-003.
type MostFrequentSalesRequest = z.infer<typeof mostFrequentSalesSchema>;
/**
* GET /api/stats/most-frequent-sales - Get a list of items that have been on sale most frequently.
* This is a public endpoint for data analysis.
*/
router.get('/most-frequent-sales', validateRequest(mostFrequentSalesSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const { days, limit } = req.query as unknown as { days: number, limit: number }; // Guaranteed to be valid numbers by the middleware
const { query: { days, limit } } = req as unknown as MostFrequentSalesRequest; // Guaranteed to be valid numbers by the middleware
const items = await db.adminRepo.getMostFrequentSaleItems(days, limit, req.log);
res.json(items);
} catch (error) {

View File

@@ -42,6 +42,13 @@ vi.mock('../services/logger.server', () => ({
// Create a minimal Express app to host our router
const app = express();
app.use(express.json());
// Add a middleware to inject a mock req.log object for tests
app.use((req, res, next) => {
req.log = { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() } as any;
next();
});
app.use('/api/system', systemRouter);
app.use(errorHandler);
@@ -147,5 +154,13 @@ describe('System Routes (/api/system)', () => {
const response = await supertest(app).post('/api/system/geocode').send({ address: 'Any Address' });
expect(response.status).toBe(500);
});
it('should return 400 if the address is missing from the body', async () => {
const response = await supertest(app)
.post('/api/system/geocode')
.send({ not_address: 'Victoria, BC' });
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('An address string is required.');
});
});
});

View File

@@ -13,11 +13,15 @@ const geocodeSchema = z.object({
address: z.string().min(1, 'An address string is required.'),
}),
});
// An empty schema for routes that do not expect any input, to maintain a consistent validation pattern.
const emptySchema = z.object({});
/**
* Checks the status of the 'flyer-crawler-api' process managed by PM2.
* This is intended for development and diagnostic purposes.
*/
router.get('/pm2-status', (req: Request, res: Response, next: NextFunction) => {
router.get('/pm2-status', validateRequest(emptySchema), (req: Request, res: Response, next: NextFunction) => {
// The name 'flyer-crawler-api' comes from your ecosystem.config.cjs file.
exec('pm2 describe flyer-crawler-api', (error, stdout, stderr) => {
if (error) {
@@ -50,7 +54,9 @@ router.get('/pm2-status', (req: Request, res: Response, next: NextFunction) => {
* This acts as a secure proxy to the Google Maps Geocoding API.
*/
router.post('/geocode', validateRequest(geocodeSchema), async (req: Request, res: Response, next: NextFunction) => {
const { address } = req.body;
// Infer type and cast request object as per ADR-003
type GeocodeRequest = z.infer<typeof geocodeSchema>;
const { body: { address } } = req as unknown as GeocodeRequest;
try {
const coordinates = await geocodingService.geocodeAddress(address, req.log);

View File

@@ -260,6 +260,12 @@ describe('User Routes (/api/users)', () => {
const response = await supertest(app).delete('/api/users/shopping-lists/999');
expect(response.status).toBe(404);
});
it('should return 400 for an invalid listId', async () => {
const response = await supertest(app).delete('/api/users/shopping-lists/abc');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe("Invalid ID for parameter 'listId'. Must be a number.");
});
});
});
describe('Shopping List Item Routes', () => { it('POST /shopping-lists/:listId/items should add an item to a list', async () => {
@@ -327,6 +333,14 @@ describe('User Routes (/api/users)', () => {
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedProfile);
});
it('should return 400 if the body is empty', async () => {
const response = await supertest(app)
.put('/api/users/profile')
.send({});
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe('At least one field to update must be provided.');
});
});
describe('PUT /profile/password', () => {
@@ -590,6 +604,12 @@ describe('User Routes (/api/users)', () => {
expect(response.status).toBe(400);
expect(response.body.message).toBe('No avatar file uploaded.');
});
it('should return 400 for a non-numeric address ID', async () => {
const response = await supertest(app).get('/api/users/addresses/abc');
expect(response.status).toBe(400);
expect(response.body.errors[0].message).toBe("Invalid ID for parameter 'addressId'. Must be a number.");
});
});
describe('Recipe Routes', () => {

View File

@@ -57,6 +57,9 @@ const notificationQuerySchema = z.object({
}),
});
// An empty schema for routes that do not expect any input, to maintain a consistent validation pattern.
const emptySchema = z.object({});
// Any request to a /api/users/* endpoint will now require a valid JWT.
router.use(passport.authenticate('jwt', { session: false }));
@@ -109,13 +112,15 @@ router.post(
* GET /api/users/notifications - Get notifications for the authenticated user.
* Supports pagination with `limit` and `offset` query parameters.
*/
type GetNotificationsRequest = z.infer<typeof notificationQuerySchema>;
router.get(
'/notifications',
validateRequest(notificationQuerySchema),
async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User;
const { limit, offset } = req.query as unknown as { limit: number; offset: number };
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, limit, offset, req.log);
// Apply ADR-003 pattern for type safety
const { query } = req as unknown as GetNotificationsRequest;
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, query.limit, query.offset, req.log);
res.json(notifications);
}
);
@@ -125,6 +130,7 @@ router.get(
*/
router.post(
'/notifications/mark-all-read',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User;
await db.notificationRepo.markAllNotificationsAsRead(user.user_id, req.log);
@@ -135,13 +141,16 @@ router.post(
/**
* POST /api/users/notifications/:notificationId/mark-read - Mark a single notification as read.
*/
const notificationIdSchema = numericIdParam('notificationId');
type MarkNotificationReadRequest = z.infer<typeof notificationIdSchema>;
router.post(
'/notifications/:notificationId/mark-read', validateRequest(numericIdParam('notificationId')),
'/notifications/:notificationId/mark-read', validateRequest(notificationIdSchema),
async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User;
const notificationId = req.params.notificationId as unknown as number;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as MarkNotificationReadRequest;
await db.notificationRepo.markNotificationAsRead(notificationId, user.user_id, req.log);
await db.notificationRepo.markNotificationAsRead(params.notificationId, user.user_id, req.log);
res.status(204).send(); // Success, no content to return
}
);
@@ -149,7 +158,7 @@ router.post(
/**
* GET /api/users/profile - Get the full profile for the authenticated user.
*/
router.get('/profile', async (req, res, next: NextFunction) => {
router.get('/profile', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/profile - ENTER`);
const user = req.user as UserProfile;
try {
@@ -165,12 +174,14 @@ router.get('/profile', async (req, res, next: NextFunction) => {
/**
* PUT /api/users/profile - Update the user's profile information.
*/
type UpdateProfileRequest = z.infer<typeof updateProfileSchema>;
router.put('/profile', validateRequest(updateProfileSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as UpdateProfileRequest;
try {
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, req.body, req.log);
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, body, req.log);
res.json(updatedProfile);
} catch (error) {
logger.error({ error }, `[ROUTE] PUT /api/users/profile - ERROR`);
@@ -181,21 +192,22 @@ router.put('/profile', validateRequest(updateProfileSchema), async (req, res, ne
/**
* PUT /api/users/profile/password - Update the user's password.
*/
type UpdatePasswordRequest = z.infer<typeof updatePasswordSchema>;
router.put('/profile/password', validateRequest(updatePasswordSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`);
const user = req.user as UserProfile;
const { newPassword } = req.body;
const { body } = req as unknown as UpdatePasswordRequest;
const MIN_PASSWORD_SCORE = 3;
const strength = zxcvbn(newPassword);
const strength = zxcvbn(body.newPassword);
if (strength.score < MIN_PASSWORD_SCORE) {
const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
return res.status(400).json({ message: `New password is too weak. ${feedback || ''}`.trim() });
}
try {
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
const saltRounds = 10; // This was a duplicate, fixed.
const hashedPassword = await bcrypt.hash(body.newPassword, saltRounds);
await db.userRepo.updateUserPassword(user.user_id, hashedPassword, req.log);
res.status(200).json({ message: 'Password updated successfully.' });
} catch (error) {
@@ -207,10 +219,11 @@ router.put('/profile/password', validateRequest(updatePasswordSchema), async (re
/**
* DELETE /api/users/account - Delete the user's own account.
*/
type DeleteAccountRequest = z.infer<typeof deleteAccountSchema>;
router.delete('/account', validateRequest(deleteAccountSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`);
const user = req.user as UserProfile;
const { password } = req.body;
const { body } = req as unknown as DeleteAccountRequest;
try {
const userWithHash = await db.userRepo.findUserWithPasswordHashById(user.user_id, req.log);
@@ -219,7 +232,7 @@ router.delete('/account', validateRequest(deleteAccountSchema), async (req, res,
}
// Per ADR-001, findUserWithPasswordHashById will throw if user or hash is missing.
const isMatch = await bcrypt.compare(password, userWithHash.password_hash);
const isMatch = await bcrypt.compare(body.password, userWithHash.password_hash);
if (!isMatch) {
return res.status(403).json({ message: 'Incorrect password.' });
}
@@ -235,7 +248,7 @@ router.delete('/account', validateRequest(deleteAccountSchema), async (req, res,
/**
* GET /api/users/watched-items - Get all watched items for the authenticated user.
*/
router.get('/watched-items', async (req, res, next: NextFunction) => {
router.get('/watched-items', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/watched-items - ENTER`);
const user = req.user as UserProfile;
try {
@@ -250,12 +263,13 @@ router.get('/watched-items', async (req, res, next: NextFunction) => {
/**
* POST /api/users/watched-items - Add a new item to the user's watchlist.
*/
type AddWatchedItemRequest = z.infer<typeof addWatchedItemSchema>;
router.post('/watched-items', validateRequest(addWatchedItemSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`);
const user = req.user as UserProfile;
const { itemName, category } = req.body;
const { body } = req as unknown as AddWatchedItemRequest;
try {
const newItem = await db.personalizationRepo.addWatchedItem(user.user_id, itemName, category, req.log);
const newItem = await db.personalizationRepo.addWatchedItem(user.user_id, body.itemName, body.category, req.log);
res.status(201).json(newItem);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
@@ -273,12 +287,14 @@ router.post('/watched-items', validateRequest(addWatchedItemSchema), async (req,
/**
* DELETE /api/users/watched-items/:masterItemId - Remove an item from the watchlist.
*/
router.delete('/watched-items/:masterItemId', validateRequest(numericIdParam('masterItemId')), async (req, res, next: NextFunction) => {
const watchedItemIdSchema = numericIdParam('masterItemId');
type DeleteWatchedItemRequest = z.infer<typeof watchedItemIdSchema>;
router.delete('/watched-items/:masterItemId', validateRequest(watchedItemIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`);
const user = req.user as UserProfile;
const masterItemId = req.params.masterItemId as unknown as number;
const { params } = req as unknown as DeleteWatchedItemRequest;
try {
await db.personalizationRepo.removeWatchedItem(user.user_id, masterItemId, req.log);
await db.personalizationRepo.removeWatchedItem(user.user_id, params.masterItemId, req.log);
res.status(204).send();
} catch (error) {
logger.error({ error }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`);
@@ -289,7 +305,7 @@ router.delete('/watched-items/:masterItemId', validateRequest(numericIdParam('ma
/**
* GET /api/users/shopping-lists - Get all shopping lists for the user.
*/
router.get('/shopping-lists', async (req, res, next: NextFunction) => {
router.get('/shopping-lists', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/shopping-lists - ENTER`);
const user = req.user as UserProfile;
try {
@@ -304,16 +320,17 @@ router.get('/shopping-lists', async (req, res, next: NextFunction) => {
/**
* GET /api/users/shopping-lists/:listId - Get a single shopping list by its ID.
*/
router.get('/shopping-lists/:listId', validateRequest(numericIdParam('listId')), async (req, res, next: NextFunction) => {
const shoppingListIdSchema = numericIdParam('listId');
type GetShoppingListRequest = z.infer<typeof shoppingListIdSchema>;
router.get('/shopping-lists/:listId', validateRequest(shoppingListIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/shopping-lists/:listId - ENTER`);
const user = req.user as UserProfile;
const listId = req.params.listId as unknown as number;
const { params } = req as unknown as GetShoppingListRequest;
try {
const list = await db.shoppingRepo.getShoppingListById(listId, user.user_id, req.log);
const list = await db.shoppingRepo.getShoppingListById(params.listId, user.user_id, req.log);
res.json(list);
} catch (error) {
logger.error({ error, listId }, `[ROUTE] GET /api/users/shopping-lists/:listId - ERROR`);
logger.error({ error, listId: params.listId }, `[ROUTE] GET /api/users/shopping-lists/:listId - ERROR`);
next(error);
}
});
@@ -321,12 +338,13 @@ router.get('/shopping-lists/:listId', validateRequest(numericIdParam('listId')),
/**
* POST /api/users/shopping-lists - Create a new shopping list.
*/
type CreateShoppingListRequest = z.infer<typeof createShoppingListSchema>;
router.post('/shopping-lists', validateRequest(createShoppingListSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`);
const user = req.user as UserProfile;
const { name } = req.body;
const { body } = req as unknown as CreateShoppingListRequest;
try {
const newList = await db.shoppingRepo.createShoppingList(user.user_id, name, req.log);
const newList = await db.shoppingRepo.createShoppingList(user.user_id, body.name, req.log);
res.status(201).json(newList);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
@@ -344,12 +362,12 @@ router.post('/shopping-lists', validateRequest(createShoppingListSchema), async
/**
* DELETE /api/users/shopping-lists/:listId - Delete a shopping list.
*/
router.delete('/shopping-lists/:listId', validateRequest(numericIdParam('listId')), async (req, res, next: NextFunction) => {
router.delete('/shopping-lists/:listId', validateRequest(shoppingListIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`);
const user = req.user as UserProfile;
const listId = req.params.listId as unknown as number;
const { params } = req as unknown as GetShoppingListRequest;
try {
await db.shoppingRepo.deleteShoppingList(listId, user.user_id, req.log);
await db.shoppingRepo.deleteShoppingList(params.listId, user.user_id, req.log);
res.status(204).send();
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
@@ -361,16 +379,18 @@ router.delete('/shopping-lists/:listId', validateRequest(numericIdParam('listId'
/**
* POST /api/users/shopping-lists/:listId/items - Add an item to a shopping list.
*/
router.post('/shopping-lists/:listId/items', validateRequest(numericIdParam('listId').extend({
const addShoppingListItemSchema = shoppingListIdSchema.extend({
body: z.object({
masterItemId: z.number().int().positive().optional(),
customItemName: z.string().min(1).optional(),
}).refine(data => data.masterItemId || data.customItemName, { message: 'Either masterItemId or customItemName must be provided.' }),
})), async (req, res, next: NextFunction) => {
});
type AddShoppingListItemRequest = z.infer<typeof addShoppingListItemSchema>;
router.post('/shopping-lists/:listId/items', validateRequest(addShoppingListItemSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
const listId = req.params.listId as unknown as number;
const { params, body } = req as unknown as AddShoppingListItemRequest;
try {
const newItem = await db.shoppingRepo.addShoppingListItem(listId, req.body, req.log);
const newItem = await db.shoppingRepo.addShoppingListItem(params.listId, body, req.log);
res.status(201).json(newItem);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
@@ -388,16 +408,18 @@ router.post('/shopping-lists/:listId/items', validateRequest(numericIdParam('lis
/**
* PUT /api/users/shopping-lists/items/:itemId - Update a shopping list item.
*/
router.put('/shopping-lists/items/:itemId', validateRequest(numericIdParam('itemId').extend({
const updateShoppingListItemSchema = numericIdParam('itemId').extend({
body: z.object({
quantity: z.number().int().nonnegative().optional(),
is_purchased: z.boolean().optional(),
}).refine(data => Object.keys(data).length > 0, { message: 'At least one field (quantity, is_purchased) must be provided.' }),
})), async (req, res, next: NextFunction) => {
});
type UpdateShoppingListItemRequest = z.infer<typeof updateShoppingListItemSchema>;
router.put('/shopping-lists/items/:itemId', validateRequest(updateShoppingListItemSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
const itemId = req.params.itemId as unknown as number;
const { params, body } = req as unknown as UpdateShoppingListItemRequest;
try {
const updatedItem = await db.shoppingRepo.updateShoppingListItem(itemId, req.body, req.log);
const updatedItem = await db.shoppingRepo.updateShoppingListItem(params.itemId, body, req.log);
res.json(updatedItem);
} catch (error: unknown) {
logger.error({ error, params: req.params, body: req.body }, `[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ERROR`);
@@ -408,11 +430,13 @@ router.put('/shopping-lists/items/:itemId', validateRequest(numericIdParam('item
/**
* DELETE /api/users/shopping-lists/items/:itemId - Remove an item from a shopping list.
*/
router.delete('/shopping-lists/items/:itemId', validateRequest(numericIdParam('itemId')), async (req, res, next: NextFunction) => {
const shoppingListItemIdSchema = numericIdParam('itemId');
type DeleteShoppingListItemRequest = z.infer<typeof shoppingListItemIdSchema>;
router.delete('/shopping-lists/items/:itemId', validateRequest(shoppingListItemIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
const itemId = req.params.itemId as unknown as number;
const { params } = req as unknown as DeleteShoppingListItemRequest;
try {
await db.shoppingRepo.removeShoppingListItem(itemId, req.log);
await db.shoppingRepo.removeShoppingListItem(params.itemId, req.log);
res.status(204).send();
} catch (error: unknown) {
logger.error({ error, params: req.params }, `[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ERROR`);
@@ -423,13 +447,16 @@ router.delete('/shopping-lists/items/:itemId', validateRequest(numericIdParam('i
/**
* PUT /api/users/profile/preferences - Update user preferences.
*/
router.put('/profile/preferences', validateRequest(z.object({
const updatePreferencesSchema = z.object({
body: z.object({}).passthrough(), // Ensures body is an object, allows any properties
})), async (req, res, next: NextFunction) => {
});
type UpdatePreferencesRequest = z.infer<typeof updatePreferencesSchema>;
router.put('/profile/preferences', validateRequest(updatePreferencesSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`);
const user = req.user as UserProfile;
try { // This was a duplicate, fixed.
const updatedProfile = await db.userRepo.updateUserPreferences(user.user_id, req.body, req.log);
const { body } = req as unknown as UpdatePreferencesRequest;
try {
const updatedProfile = await db.userRepo.updateUserPreferences(user.user_id, body, req.log);
res.json(updatedProfile);
} catch (error) {
logger.error({ error }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`);
@@ -437,7 +464,7 @@ router.put('/profile/preferences', validateRequest(z.object({
}
});
router.get('/me/dietary-restrictions', async (req, res, next: NextFunction) => {
router.get('/me/dietary-restrictions', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/me/dietary-restrictions - ENTER`);
const user = req.user as UserProfile;
try {
@@ -449,14 +476,16 @@ router.get('/me/dietary-restrictions', async (req, res, next: NextFunction) => {
}
});
router.put('/me/dietary-restrictions', validateRequest(z.object({
const setUserRestrictionsSchema = z.object({
body: z.object({ restrictionIds: z.array(z.number().int().positive()) }),
})), async (req, res, next: NextFunction) => {
});
type SetUserRestrictionsRequest = z.infer<typeof setUserRestrictionsSchema>;
router.put('/me/dietary-restrictions', validateRequest(setUserRestrictionsSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`);
const user = req.user as UserProfile;
const { restrictionIds } = req.body;
const { body } = req as unknown as SetUserRestrictionsRequest;
try {
await db.personalizationRepo.setUserDietaryRestrictions(user.user_id, restrictionIds, req.log);
await db.personalizationRepo.setUserDietaryRestrictions(user.user_id, body.restrictionIds, req.log);
res.status(204).send();
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
@@ -471,7 +500,7 @@ router.put('/me/dietary-restrictions', validateRequest(z.object({
}
});
router.get('/me/appliances', async (req, res, next: NextFunction) => {
router.get('/me/appliances', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] GET /api/users/me/appliances - ENTER`);
const user = req.user as UserProfile;
try {
@@ -483,14 +512,16 @@ router.get('/me/appliances', async (req, res, next: NextFunction) => {
}
});
router.put('/me/appliances', validateRequest(z.object({
const setUserAppliancesSchema = z.object({
body: z.object({ applianceIds: z.array(z.number().int().positive()) }),
})), async (req, res, next: NextFunction) => {
});
type SetUserAppliancesRequest = z.infer<typeof setUserAppliancesSchema>;
router.put('/me/appliances', validateRequest(setUserAppliancesSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`);
const user = req.user as UserProfile;
const { applianceIds } = req.body;
const { body } = req as unknown as SetUserAppliancesRequest;
try {
await db.personalizationRepo.setUserAppliances(user.user_id, applianceIds, req.log);
await db.personalizationRepo.setUserAppliances(user.user_id, body.applianceIds, req.log);
res.status(204).send();
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
@@ -509,10 +540,12 @@ router.put('/me/appliances', validateRequest(z.object({
* GET /api/users/addresses/:addressId - Get a specific address by its ID.
* This is protected to ensure a user can only fetch their own address details.
*/
router.get('/addresses/:addressId', validateRequest(numericIdParam('addressId')), async (req, res, next: NextFunction) => {
const addressIdSchema = numericIdParam('addressId');
type GetAddressRequest = z.infer<typeof addressIdSchema>;
router.get('/addresses/:addressId', validateRequest(addressIdSchema), async (req, res, next: NextFunction) => {
const user = req.user as UserProfile;
const addressId = req.params.addressId as unknown as number;
const { params } = req as unknown as GetAddressRequest;
const addressId = params.addressId;
// Security check: Ensure the requested addressId matches the one on the user's profile.
if (user.address_id !== addressId) {
return res.status(403).json({ message: 'Forbidden: You can only access your own address.' });
@@ -525,7 +558,7 @@ router.get('/addresses/:addressId', validateRequest(numericIdParam('addressId'))
/**
* PUT /api/users/profile/address - Create or update the user's primary address.
*/
router.put('/profile/address', validateRequest(z.object({
const updateUserAddressSchema = z.object({
body: z.object({
address_line_1: z.string().optional(),
address_line_2: z.string().optional(),
@@ -534,9 +567,11 @@ router.put('/profile/address', validateRequest(z.object({
postal_code: z.string().optional(),
country: z.string().optional(),
}).refine(data => Object.keys(data).length > 0, { message: 'At least one address field must be provided.' }),
})), async (req, res, next: NextFunction) => {
});
type UpdateUserAddressRequest = z.infer<typeof updateUserAddressSchema>;
router.put('/profile/address', validateRequest(updateUserAddressSchema), async (req, res, next: NextFunction) => {
const user = req.user as UserProfile;
const addressData = req.body as Partial<Address>;
const { body: addressData } = req as unknown as UpdateUserAddressRequest;
try {
// Per ADR-002, complex operations involving multiple database writes should be
@@ -552,13 +587,14 @@ router.put('/profile/address', validateRequest(z.object({
/**
* DELETE /api/users/recipes/:recipeId - Delete a recipe created by the user.
*/
router.delete('/recipes/:recipeId', validateRequest(numericIdParam('recipeId')), async (req, res, next: NextFunction) => {
const recipeIdSchema = numericIdParam('recipeId');
type DeleteRecipeRequest = z.infer<typeof recipeIdSchema>;
router.delete('/recipes/:recipeId', validateRequest(recipeIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`);
const user = req.user as UserProfile;
const recipeId = req.params.recipeId as unknown as number;
const { params } = req as unknown as DeleteRecipeRequest;
try {
await db.recipeRepo.deleteRecipe(recipeId, user.user_id, false, req.log);
await db.recipeRepo.deleteRecipe(params.recipeId, user.user_id, false, req.log);
res.status(204).send();
} catch (error) {
next(error);
@@ -568,7 +604,7 @@ router.delete('/recipes/:recipeId', validateRequest(numericIdParam('recipeId')),
/**
* PUT /api/users/recipes/:recipeId - Update a recipe created by the user.
*/
router.put('/recipes/:recipeId', validateRequest(numericIdParam('recipeId').extend({
const updateRecipeSchema = recipeIdSchema.extend({
body: z.object({
name: z.string().optional(),
description: z.string().optional(),
@@ -578,13 +614,15 @@ router.put('/recipes/:recipeId', validateRequest(numericIdParam('recipeId').exte
servings: z.number().int().optional(),
photo_url: z.string().url().optional(),
}).refine(data => Object.keys(data).length > 0, { message: 'No fields provided to update.' }),
})), async (req, res, next: NextFunction) => {
});
type UpdateRecipeRequest = z.infer<typeof updateRecipeSchema>;
router.put('/recipes/:recipeId', validateRequest(updateRecipeSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`);
const user = req.user as UserProfile;
const recipeId = req.params.recipeId as unknown as number;
const { params, body } = req as unknown as UpdateRecipeRequest;
try {
const updatedRecipe = await db.recipeRepo.updateRecipe(recipeId, user.user_id, req.body, req.log);
const updatedRecipe = await db.recipeRepo.updateRecipe(params.recipeId, user.user_id, body, req.log);
res.json(updatedRecipe);
} catch (error) {
next(error);