route testing refactoring using zod - ADR-003
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m3s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m3s
This commit is contained in:
@@ -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}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user