route testing refactoring using zod - ADR-003
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m3s

This commit is contained in:
2025-12-12 13:16:58 -08:00
parent d004efb84b
commit e37a32c890
27 changed files with 481 additions and 856 deletions

View File

@@ -2,7 +2,8 @@
import { Router, Request, Response, NextFunction } from 'express';
import passport from './passport.routes';
import { isAdmin } from './passport.routes'; // Correctly imported
import multer from 'multer';
import multer from 'multer';// --- Zod Schemas for Admin Routes (as per ADR-003) ---
import { z } from 'zod';
import crypto from 'crypto';
import * as db from '../services/db/index.db';
@@ -10,6 +11,7 @@ import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { clearGeocodeCache } from '../services/geocodingService.server';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
// --- Bull Board (Job Queue UI) Imports ---
import { createBullBoard } from '@bull-board/api';
@@ -22,6 +24,42 @@ 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 numericIdParamSchema = (key: string) => z.object({
params: z.object({ [key]: z.coerce.number().int().positive() }),
});
const updateCorrectionSchema = numericIdParamSchema('id').extend({
body: z.object({
suggested_value: z.string().min(1, 'A new suggested_value is required.'),
}),
});
const updateRecipeStatusSchema = numericIdParamSchema('id').extend({
body: z.object({
status: z.enum(['private', 'pending_review', 'public', 'rejected']),
}),
});
const updateCommentStatusSchema = numericIdParamSchema('id').extend({
body: z.object({
status: z.enum(['visible', 'hidden', 'reported']),
}),
});
const updateUserRoleSchema = z.object({
params: z.object({ id: z.string().uuid("User ID must be a valid UUID.") }),
body: z.object({
role: z.enum(['user', 'admin']),
}),
});
const activityLogSchema = z.object({
query: z.object({
limit: z.coerce.number().int().positive().optional().default(50),
offset: z.coerce.number().int().nonnegative().optional().default(0),
}),
});
const router = Router();
// --- Multer Configuration for File Uploads ---
@@ -103,13 +141,9 @@ router.get('/stats/daily', async (req, res, next: NextFunction) => {
}
});
router.post('/corrections/:id/approve', async (req, res, next: NextFunction) => {
const correctionId = parseInt(req.params.id, 10);
// Add validation to ensure the ID is a valid number.
if (isNaN(correctionId)) {
return res.status(400).json({ message: 'Invalid correction ID provided.' });
}
router.post('/corrections/:id/approve', validateRequest(numericIdParamSchema('id')), async (req, res, next: NextFunction) => {
try {
const correctionId = req.params.id as unknown as number;
await db.adminRepo.approveCorrection(correctionId);
res.status(200).json({ message: 'Correction approved successfully.' });
} catch (error) {
@@ -117,9 +151,9 @@ router.post('/corrections/:id/approve', async (req, res, next: NextFunction) =>
}
});
router.post('/corrections/:id/reject', async (req, res, next: NextFunction) => {
router.post('/corrections/:id/reject', validateRequest(numericIdParamSchema('id')), async (req, res, next: NextFunction) => {
try {
const correctionId = parseInt(req.params.id, 10);
const correctionId = req.params.id as unknown as number;
await db.adminRepo.rejectCorrection(correctionId);
res.status(200).json({ message: 'Correction rejected successfully.' });
} catch (error) {
@@ -127,29 +161,20 @@ router.post('/corrections/:id/reject', async (req, res, next: NextFunction) => {
}
});
router.put('/corrections/:id', async (req, res, next: NextFunction) => {
const correctionId = parseInt(req.params.id, 10);
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;
if (!suggested_value) {
return res.status(400).json({ message: 'A new suggested_value is required.' });
}
try {
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(correctionId, suggested_value);
res.status(200).json(updatedCorrection);
} catch (error) {
// The custom error handler now correctly interprets "not found" messages.
// We can simplify this by just passing the error along.
next(error);
}
});
router.put('/recipes/:id/status', async (req, res, next: NextFunction) => {
const recipeId = parseInt(req.params.id, 10);
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;
if (!status || !['private', 'pending_review', 'public', 'rejected'].includes(status)) {
return res.status(400).json({ message: 'A valid status (private, pending_review, public, rejected) is required.' });
}
try {
const updatedRecipe = await db.adminRepo.updateRecipeStatus(recipeId, status); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedRecipe);
@@ -158,8 +183,8 @@ router.put('/recipes/:id/status', async (req, res, next: NextFunction) => {
}
});
router.post('/brands/:id/logo', upload.single('logoImage'), async (req, res, next: NextFunction) => {
const brandId = parseInt(req.params.id, 10);
router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('id')), upload.single('logoImage'), async (req, res, next: NextFunction) => {
const brandId = req.params.id as unknown as number;
if (!req.file) {
return res.status(400).json({ message: 'Logo image file is required.' });
}
@@ -187,13 +212,9 @@ router.get('/unmatched-items', async (req, res, next: NextFunction) => {
/**
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
*/
router.delete('/recipes/:recipeId', async (req, res, next: NextFunction) => {
router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipeId')), async (req, res, next: NextFunction) => {
const adminUser = req.user as UserProfile;
const recipeId = parseInt(req.params.recipeId, 10);
if (isNaN(recipeId)) {
return res.status(400).json({ message: 'Invalid recipe ID.' });
}
const recipeId = req.params.recipeId as unknown as number;
try {
// The isAdmin flag bypasses the ownership check in the repository method.
@@ -207,13 +228,8 @@ router.delete('/recipes/:recipeId', async (req, res, next: NextFunction) => {
/**
* DELETE /api/admin/flyers/:flyerId - Admin endpoint to delete a flyer and its items.
*/
router.delete('/flyers/:flyerId', async (req, res, next: NextFunction) => {
const flyerId = parseInt(req.params.flyerId, 10);
if (isNaN(flyerId)) {
return res.status(400).json({ message: 'Invalid flyer ID.' });
}
router.delete('/flyers/:flyerId', validateRequest(numericIdParamSchema('flyerId')), async (req, res, next: NextFunction) => {
const flyerId = req.params.flyerId as unknown as number;
try {
await db.flyerRepo.deleteFlyer(flyerId);
res.status(204).send();
@@ -222,13 +238,9 @@ router.delete('/flyers/:flyerId', async (req, res, next: NextFunction) => {
}
});
router.put('/comments/:id/status', async (req, res, next: NextFunction) => {
const commentId = parseInt(req.params.id, 10);
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;
if (!status || !['visible', 'hidden', 'reported'].includes(status as string)) {
return res.status(400).json({ message: 'A valid status (visible, hidden, reported) is required.' });
}
try {
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(commentId, status); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedComment);
@@ -246,9 +258,8 @@ router.get('/users', async (req, res, next: NextFunction) => {
}
});
router.get('/activity-log', async (req, res, next: NextFunction) => {
const limit = parseInt(req.query.limit as string, 10) || 50;
const offset = parseInt(req.query.offset as string, 10) || 0;
router.get('/activity-log', validateRequest(activityLogSchema), async (req, res, next: NextFunction) => {
const { limit, offset } = req.query as unknown as { limit: number; offset: number };
try {
const logs = await db.adminRepo.getActivityLog(limit, offset);
res.json(logs);
@@ -257,7 +268,7 @@ router.get('/activity-log', async (req, res, next: NextFunction) => {
}
});
router.get('/users/:id', async (req, res, next: NextFunction) => {
router.get('/users/:id', validateRequest(z.object({ params: z.object({ id: z.string().uuid() }) })), async (req, res, next: NextFunction) => {
try {
const user = await db.userRepo.findUserProfileById(req.params.id);
res.json(user);
@@ -266,11 +277,8 @@ router.get('/users/:id', async (req, res, next: NextFunction) => {
}
});
router.put('/users/:id', async (req, res, next: NextFunction) => {
router.put('/users/:id', validateRequest(updateUserRoleSchema), async (req, res, next: NextFunction) => {
const { role } = req.body;
if (!role || !['user', 'admin'].includes(role)) {
return res.status(400).json({ message: 'A valid role ("user" or "admin") is required.' });
}
try {
const updatedUser = await db.adminRepo.updateUserRole(req.params.id, role);
@@ -281,7 +289,7 @@ router.put('/users/:id', async (req, res, next: NextFunction) => {
}
});
router.delete('/users/:id', async (req, res, next: NextFunction) => {
router.delete('/users/:id', validateRequest(z.object({ params: z.object({ id: z.string().uuid() }) })), async (req, res, next: NextFunction) => {
const adminUser = req.user as UserProfile;
if (adminUser.user.user_id === req.params.id) {
return res.status(400).json({ message: 'Admins cannot delete their own account.' });
@@ -339,13 +347,9 @@ 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', async (req, res, next: NextFunction) => {
router.post('/flyers/:flyerId/cleanup', validateRequest(numericIdParamSchema('flyerId')), async (req, res, next: NextFunction) => {
const adminUser = req.user as UserProfile;
const flyerId = parseInt(req.params.flyerId, 10);
if (isNaN(flyerId)) {
return res.status(400).json({ message: 'A valid flyer ID is required.' });
}
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}`);