// 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 type { Queue } from 'bullmq'; import { backgroundJobService } from '../services/backgroundJobService'; import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue, } from '../services/queueService.server'; // Import your queues import { analyticsWorker, cleanupWorker, emailWorker, flyerWorker, weeklyAnalyticsWorker, } from '../services/workers.server'; import { getSimpleWeekAndYear } from '../utils/dateUtils'; import { requiredString, numericIdParam, uuidParamSchema, optionalNumeric, } from '../utils/zodUtils'; import { logger } from '../services/logger.server'; import fs from 'node:fs/promises'; /** * Safely deletes a file from the filesystem, ignoring errors if the file doesn't exist. * @param file The multer file object to delete. */ const cleanupUploadedFile = async (file?: Express.Multer.File) => { if (!file) return; try { await fs.unlink(file.path); } catch (err) { logger.warn({ err, filePath: file.path }, 'Failed to clean up uploaded logo file.'); } }; const updateCorrectionSchema = numericIdParam('id').extend({ body: z.object({ suggested_value: requiredString('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: requiredString('A valid Job ID is required.'), }), }); const router = Router(); const upload = createUploadMiddleware({ storageType: 'flyer' }); // --- Bull Board (Job Queue UI) Setup --- const serverAdapter = new ExpressAdapter(); serverAdapter.setBasePath('/api/admin/jobs'); // Set the base path for the UI createBullBoard({ queues: [ new BullMQAdapter(flyerQueue), new BullMQAdapter(emailQueue), new BullMQAdapter(analyticsQueue), new BullMQAdapter(cleanupQueue), new BullMQAdapter(weeklyAnalyticsQueue), // Add the weekly analytics queue to the board ], options: { uiConfig: { boardTitle: 'Bull Dashboard', }, }, serverAdapter: serverAdapter, }); // Mount the Bull Board UI router. This must be done BEFORE the isAdmin middleware // so the UI's own assets can be served, but the routes within it will be protected // by the router-level `isAdmin` middleware below. router.use('/jobs', serverAdapter.getRouter()); // --- Middleware for all admin routes --- router.use(passport.authenticate('jwt', { session: false }), isAdmin); // --- Admin Routes --- router.get('/corrections', async (req, res, next: NextFunction) => { try { const corrections = await db.adminRepo.getSuggestedCorrections(req.log); res.json(corrections); } catch (error) { logger.error({ error }, 'Error fetching suggested corrections'); next(error); } }); router.get('/brands', 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', 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', 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>; 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>; 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; 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; 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')), upload.single('logoImage'), requireFileUpload('logoImage'), async (req: Request, res: Response, next: NextFunction) => { // Apply ADR-003 pattern for type safety const { params } = req as unknown as z.infer>; 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.'); } // The storage path is 'flyer-images', so the URL should reflect that for consistency. const logoUrl = `/flyer-images/${req.file.filename}`; await db.adminRepo.updateBrandLogo(params.id, logoUrl, 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', 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>; 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>; 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; 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', 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 explicitly coerce query params here because the validation middleware might not // replace req.query with the coerced values in all environments. const query = req.query as unknown as { limit?: string; offset?: string }; const limit = query.limit ? Number(query.limit) : 50; const offset = query.offset ? Number(query.offset) : 0; 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>; 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; 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>; try { if (userProfile.user.user_id === params.id) { throw new ValidationError([], 'Admins cannot delete their own account.'); } await db.userRepo.deleteUserById(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', 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', 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 reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD // Use a unique job ID for manual triggers to distinguish them from scheduled jobs. const jobId = `manual-report-${reportDate}-${Date.now()}`; const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId }); res.status(202).json({ message: `Analytics report generation job has been enqueued successfully. Job ID: ${job.id}`, }); } catch (error) { logger.error({ 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>; // 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', 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', 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', async (req: Request, res: Response) => { const workers = [flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker]; const workerStatuses = await Promise.all( workers.map(async (worker) => { return { name: worker.name, isRunning: worker.isRunning(), }; }), ); res.json(workerStatuses); }); /** * GET /api/admin/queues/status - Get job counts for all BullMQ queues. * This is useful for monitoring the health and backlog of background jobs. */ router.get('/queues/status', async (req: Request, res: Response, next: NextFunction) => { try { const queues = [flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue]; const queueStatuses = await Promise.all( queues.map(async (queue) => { return { name: queue.name, counts: await queue.getJobCounts( 'waiting', 'active', 'completed', 'failed', 'delayed', 'paused', ), }; }), ); res.json(queueStatuses); } catch (error) { 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; const queueMap: { [key: string]: Queue } = { 'flyer-processing': flyerQueue, 'email-sending': emailQueue, 'analytics-reporting': analyticsQueue, 'file-cleanup': cleanupQueue, }; const queue = queueMap[queueName]; if (!queue) { // Throw a NotFoundError to be handled by the central error handler. throw new NotFoundError(`Queue '${queueName}' not found.`); } try { const job = await queue.getJob(jobId); if (!job) throw new NotFoundError(`Job with ID '${jobId}' not found in queue '${queueName}'.`); const jobState = await job.getState(); if (jobState !== 'failed') throw new ValidationError( [], `Job is not in a 'failed' state. Current state: ${jobState}.`, ); // This was a duplicate, fixed. await job.retry(); logger.info( `[Admin] User ${userProfile.user.user_id} manually retried job ${jobId} in queue ${queueName}.`, ); 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', 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 { year: reportYear, week: reportWeek } = getSimpleWeekAndYear(); const { weeklyAnalyticsQueue } = await import('../services/queueService.server'); const job = await weeklyAnalyticsQueue.add( 'generate-weekly-report', { reportYear, reportWeek }, { jobId: `manual-weekly-report-${reportYear}-${reportWeek}-${Date.now()}`, // Add timestamp to avoid ID conflict }, ); res .status(202) .json({ message: 'Successfully enqueued weekly analytics job.', jobId: job.id }); } catch (error) { 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;