Files
flyer-crawler.projectium.com/src/routes/admin.routes.ts
Torben Sorensen cadacb63f5
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m54s
fix unit tests
2025-12-30 03:19:47 -08:00

633 lines
21 KiB
TypeScript

// src/routes/admin.routes.ts
import { Router, NextFunction, Request, Response } from 'express';
import passport from './passport.routes';
import { isAdmin } from './passport.routes'; // Correctly imported
import multer from 'multer';
import { z } from 'zod';
import * as db from '../services/db/index.db';
import type { UserProfile } from '../types';
import { geocodingService } from '../services/geocodingService.server';
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { NotFoundError, ValidationError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
// --- Bull Board (Job Queue UI) Imports ---
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { backgroundJobService } from '../services/backgroundJobService';
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue } from '../services/queueService.server';
import { getSimpleWeekAndYear } from '../utils/dateUtils';
import {
requiredString,
numericIdParam,
uuidParamSchema,
optionalNumeric,
optionalString,
} from '../utils/zodUtils';
import { logger } from '../services/logger.server'; // This was a duplicate, fixed.
import { monitoringService } from '../services/monitoringService.server';
import { userService } from '../services/userService';
import { cleanupUploadedFile } from '../utils/fileUtils';
import { brandService } from '../services/brandService';
const updateCorrectionSchema = numericIdParam('id').extend({
body: z.object({
suggested_value: z.string().trim().min(1, 'A new suggested_value is required.'),
}),
});
const updateRecipeStatusSchema = numericIdParam('id').extend({
body: z.object({
status: z.enum(['private', 'pending_review', 'public', 'rejected']),
}),
});
const updateCommentStatusSchema = numericIdParam('id').extend({
body: z.object({
status: z.enum(['visible', 'hidden', 'reported']),
}),
});
const updateUserRoleSchema = uuidParamSchema('id', 'A valid user ID is required.').extend({
body: z.object({
role: z.enum(['user', 'admin']),
}),
});
const activityLogSchema = z.object({
query: z.object({
limit: optionalNumeric({ default: 50, integer: true, positive: true }),
offset: optionalNumeric({ default: 0, integer: true, nonnegative: true }),
}),
});
const jobRetrySchema = z.object({
params: z.object({
queueName: z.enum([
'flyer-processing',
'email-sending',
'analytics-reporting',
'file-cleanup',
'weekly-analytics-reporting',
]),
jobId: z.string().trim().min(1, 'A valid Job ID is required.'),
}),
});
const emptySchema = z.object({});
const router = Router();
const brandLogoUpload = createUploadMiddleware({
storageType: 'flyer', // Using flyer storage path is acceptable for brand logos.
fileSize: 2 * 1024 * 1024, // 2MB limit for logos
fileFilter: 'image',
});
// --- Bull Board (Job Queue UI) Setup ---
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/api/admin/jobs'); // Set the base path for the UI
createBullBoard({
queues: [
new BullMQAdapter(flyerQueue),
new BullMQAdapter(emailQueue),
new BullMQAdapter(analyticsQueue),
new BullMQAdapter(cleanupQueue),
new BullMQAdapter(weeklyAnalyticsQueue), // Add the weekly analytics queue to the board
],
options: {
uiConfig: {
boardTitle: 'Bull Dashboard',
},
},
serverAdapter: serverAdapter,
});
// Mount the Bull Board UI router. This must be done BEFORE the isAdmin middleware
// so the UI's own assets can be served, but the routes within it will be protected
// by the router-level `isAdmin` middleware below.
router.use('/jobs', serverAdapter.getRouter());
// --- Middleware for all admin routes ---
router.use(passport.authenticate('jwt', { session: false }), isAdmin);
// --- Admin Routes ---
router.get('/corrections', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const corrections = await db.adminRepo.getSuggestedCorrections(req.log);
res.json(corrections);
} catch (error) {
logger.error({ error }, 'Error fetching suggested corrections');
next(error);
}
});
router.get('/review/flyers', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
req.log.debug('Fetching flyers for review via adminRepo');
const flyers = await db.adminRepo.getFlyersForReview(req.log);
req.log.info({ count: Array.isArray(flyers) ? flyers.length : 'unknown' }, 'Successfully fetched flyers for review');
res.json(flyers);
} catch (error) {
logger.error({ error }, 'Error fetching flyers for review');
next(error);
}
});
router.get('/brands', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const brands = await db.flyerRepo.getAllBrands(req.log);
res.json(brands);
} catch (error) {
logger.error({ error }, 'Error fetching brands');
next(error);
}
});
router.get('/stats', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const stats = await db.adminRepo.getApplicationStats(req.log);
res.json(stats);
} catch (error) {
logger.error({ error }, 'Error fetching application stats');
next(error);
}
});
router.get('/stats/daily', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(req.log);
res.json(dailyStats);
} catch (error) {
logger.error({ error }, 'Error fetching daily stats');
next(error);
}
});
router.post(
'/corrections/:id/approve',
validateRequest(numericIdParam('id')),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try {
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction approved successfully.' });
} catch (error) {
logger.error({ error }, 'Error approving correction');
next(error);
}
},
);
router.post(
'/corrections/:id/reject',
validateRequest(numericIdParam('id')),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try {
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction rejected successfully.' });
} catch (error) {
logger.error({ error }, 'Error rejecting correction');
next(error);
}
},
);
router.put(
'/corrections/:id',
validateRequest(updateCorrectionSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as z.infer<typeof updateCorrectionSchema>;
try {
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(
params.id,
body.suggested_value,
req.log,
);
res.status(200).json(updatedCorrection);
} catch (error) {
logger.error({ error }, 'Error updating suggested correction');
next(error);
}
},
);
router.put(
'/recipes/:id/status',
validateRequest(updateRecipeStatusSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as z.infer<typeof updateRecipeStatusSchema>;
try {
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) {
logger.error({ error }, 'Error updating recipe status');
next(error); // Pass all errors to the central error handler
}
},
);
router.post(
'/brands/:id/logo',
validateRequest(numericIdParam('id')),
brandLogoUpload.single('logoImage'),
requireFileUpload('logoImage'),
async (req: Request, res: Response, next: NextFunction) => {
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try {
// Although requireFileUpload middleware should ensure the file exists,
// this check satisfies TypeScript and adds robustness.
if (!req.file) {
throw new ValidationError([], 'Logo image file is missing.');
}
const logoUrl = await brandService.updateBrandLogo(params.id, req.file, req.log);
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) {
// If an error occurs after the file has been uploaded (e.g., DB error),
// we must clean up the orphaned file from the disk.
await cleanupUploadedFile(req.file);
logger.error({ error }, 'Error updating brand logo');
next(error);
}
},
);
router.get('/unmatched-items', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
res.json(items);
} catch (error) {
logger.error({ error }, 'Error fetching unmatched items');
next(error);
}
});
/**
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
*/
router.delete(
'/recipes/:recipeId',
validateRequest(numericIdParam('recipeId')),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
// Infer the type directly from the schema generator function. // This was a duplicate, fixed.
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try {
// The isAdmin flag bypasses the ownership check in the repository method.
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, true, req.log);
res.status(204).send();
} catch (error: unknown) {
logger.error({ error }, 'Error deleting recipe');
next(error);
}
},
);
/**
* DELETE /api/admin/flyers/:flyerId - Admin endpoint to delete a flyer and its items.
*/
router.delete(
'/flyers/:flyerId',
validateRequest(numericIdParam('flyerId')),
async (req: Request, res: Response, next: NextFunction) => {
// Infer the type directly from the schema generator function.
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try {
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
res.status(204).send();
} catch (error: unknown) {
logger.error({ error }, 'Error deleting flyer');
next(error);
}
},
);
router.put(
'/comments/:id/status',
validateRequest(updateCommentStatusSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as z.infer<typeof updateCommentStatusSchema>;
try {
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) {
logger.error({ error }, 'Error updating comment status');
next(error);
}
},
);
router.get('/users', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const users = await db.adminRepo.getAllUsers(req.log);
res.json(users);
} catch (error) {
logger.error({ error }, 'Error fetching users');
next(error);
}
});
router.get(
'/activity-log',
validateRequest(activityLogSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety.
// We parse the query here to apply Zod's coercions (string to number) and defaults.
const { limit, offset } = activityLogSchema.shape.query.parse(req.query);
try {
const logs = await db.adminRepo.getActivityLog(limit!, offset!, req.log);
res.json(logs);
} catch (error) {
logger.error({ error }, 'Error fetching activity log');
next(error);
}
},
);
router.get(
'/users/:id',
validateRequest(uuidParamSchema('id', 'A valid user ID is required.')),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try {
const user = await db.userRepo.findUserProfileById(params.id, req.log);
res.json(user);
} catch (error) {
logger.error({ error }, 'Error fetching user profile');
next(error);
}
},
);
router.put(
'/users/:id',
validateRequest(updateUserRoleSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as z.infer<typeof updateUserRoleSchema>;
try {
const updatedUser = await db.adminRepo.updateUserRole(params.id, body.role, req.log);
res.json(updatedUser);
} catch (error) {
logger.error({ error }, `Error updating user ${params.id}:`);
next(error);
}
},
);
router.delete(
'/users/:id',
validateRequest(uuidParamSchema('id', 'A valid user ID is required.')),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try {
await userService.deleteUserAsAdmin(userProfile.user.user_id, params.id, req.log);
res.status(204).send();
} catch (error) {
logger.error({ error }, 'Error deleting user');
next(error);
}
},
);
/**
* POST /api/admin/trigger/daily-deal-check - Manually trigger the daily deal check job.
* This is useful for testing or forcing an update without waiting for the cron schedule.
*/
router.post(
'/trigger/daily-deal-check',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
logger.info(
`[Admin] Manual trigger for daily deal check received from user: ${userProfile.user.user_id}`,
);
try {
// We call the function but don't wait for it to finish (no `await`).
// This is a "fire-and-forget" operation from the client's perspective.
backgroundJobService.runDailyDealCheck();
res.status(202).json({
message:
'Daily deal check job has been triggered successfully. It will run in the background.',
});
} catch (error) {
logger.error({ error }, '[Admin] Failed to trigger daily deal check job.');
next(error);
}
},
);
/**
* POST /api/admin/trigger/analytics-report - Manually enqueue a job to generate the daily analytics report.
* This is useful for testing or re-generating a report without waiting for the cron schedule.
*/
router.post(
'/trigger/analytics-report',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
logger.info(
`[Admin] Manual trigger for analytics report generation received from user: ${userProfile.user.user_id}`,
);
try {
const jobId = await backgroundJobService.triggerAnalyticsReport();
res.status(202).json({
message: `Analytics report generation job has been enqueued successfully. Job ID: ${jobId}`,
});
} catch (error) {
logger.error({ error }, '[Admin] Failed to enqueue analytics report job.');
next(error);
}
},
);
/**
* POST /api/admin/flyers/:flyerId/cleanup - Enqueue a job to clean up a flyer's files.
* This is triggered by an admin after they have verified the flyer processing was successful.
*/
router.post(
'/flyers/:flyerId/cleanup',
validateRequest(numericIdParam('flyerId')),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
// Infer type from the schema generator for type safety, as per ADR-003.
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>; // This was a duplicate, fixed.
logger.info(
`[Admin] Manual trigger for flyer file cleanup received from user: ${userProfile.user.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: params.flyerId });
res
.status(202)
.json({ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` });
} catch (error) {
logger.error({ error }, 'Error enqueuing cleanup job');
next(error);
}
},
);
/**
* POST /api/admin/trigger/failing-job - Enqueue a test job designed to fail.
* This is for testing the retry mechanism and Bull Board UI.
*/
router.post(
'/trigger/failing-job',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
logger.info(
`[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`,
);
try {
// Add a job with a special 'forceFail' flag that the worker will recognize.
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
res
.status(202)
.json({ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` });
} catch (error) {
logger.error({ error }, 'Error enqueuing failing job');
next(error);
}
}
);
/**
* POST /api/admin/system/clear-geocode-cache - Clears the Redis cache for geocoded addresses.
* Requires admin privileges.
*/
router.post(
'/system/clear-geocode-cache',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
logger.info(
`[Admin] Manual trigger for geocode cache clear received from user: ${userProfile.user.user_id}`,
);
try {
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
res.status(200).json({
message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.`,
});
} catch (error) {
logger.error({ error }, '[Admin] Failed to clear geocode cache.');
next(error);
}
},
);
/**
* GET /api/admin/workers/status - Get the current running status of all BullMQ workers.
* This is useful for a system health dashboard to see if any workers have crashed.
*/
router.get('/workers/status', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const workerStatuses = await monitoringService.getWorkerStatuses();
res.json(workerStatuses);
} catch (error) {
logger.error({ error }, 'Error fetching worker statuses');
next(error);
}
});
/**
* 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', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const queueStatuses = await monitoringService.getQueueStatuses();
res.json(queueStatuses);
} catch (error) {
logger.error({ error }, 'Error fetching queue statuses');
next(error);
}
});
/**
* POST /api/admin/jobs/:queueName/:jobId/retry - Retries a specific failed job.
*/
router.post(
'/jobs/:queueName/:jobId/retry',
validateRequest(jobRetrySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
const {
params: { queueName, jobId },
} = req as unknown as z.infer<typeof jobRetrySchema>;
try {
await monitoringService.retryFailedJob(
queueName,
jobId,
userProfile.user.user_id,
);
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
} catch (error) {
logger.error({ error }, 'Error retrying job');
next(error);
}
},
);
/**
* POST /api/admin/trigger/weekly-analytics - Manually trigger the weekly analytics report job.
*/
router.post(
'/trigger/weekly-analytics',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile; // This was a duplicate, fixed.
logger.info(
`[Admin] Manual trigger for weekly analytics report received from user: ${userProfile.user.user_id}`,
);
try {
const jobId = await backgroundJobService.triggerWeeklyAnalyticsReport();
res
.status(202)
.json({ message: 'Successfully enqueued weekly analytics job.', jobId });
} catch (error) {
logger.error({ error }, 'Error enqueuing weekly analytics job');
next(error);
}
},
);
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);
export default router;