renaming
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m31s

This commit is contained in:
2025-12-04 23:57:59 -08:00
parent 6540a19ee9
commit e0af06f54c
20 changed files with 55 additions and 53 deletions

295
src/routes/admin.routes.ts Normal file
View File

@@ -0,0 +1,295 @@
// src/routes/admin.db.ts
import { Router, NextFunction } from 'express';
import passport from './passport.routes';
import { isAdmin } from './passport.routes'; // Correctly imported
import multer from 'multer';
import * as db from '../services/db/index.db';
import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { clearGeocodeCache } from '../services/geocodingService.server';
// --- 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 { runDailyDealCheck } from '../services/backgroundJobService';
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue } from '../services/queueService.server'; // Import your queues
const router = Router();
// --- Multer Configuration for File Uploads ---
const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, storagePath);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname);
}
});
const upload = multer({ storage: storage });
// --- 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) // Add the new cleanup queue here
],
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) => {
const corrections = await db.getSuggestedCorrections();
res.json(corrections);
});
router.get('/brands', async (req, res) => {
const brands = await db.getAllBrands();
res.json(brands);
});
router.get('/stats', async (req, res) => {
const stats = await db.getApplicationStats();
res.json(stats);
});
router.get('/stats/daily', async (req, res) => {
const dailyStats = await db.getDailyStatsForLast30Days();
res.json(dailyStats);
});
router.post('/corrections/:id/approve', async (req, res) => {
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.' });
}
await db.approveCorrection(correctionId);
res.status(200).json({ message: 'Correction approved successfully.' });
});
router.post('/corrections/:id/reject', async (req, res) => {
const correctionId = parseInt(req.params.id, 10);
await db.rejectCorrection(correctionId);
res.status(200).json({ message: 'Correction rejected successfully.' });
});
router.put('/corrections/:id', async (req, res, next: NextFunction) => {
const correctionId = parseInt(req.params.id, 10);
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.updateSuggestedCorrection(correctionId, suggested_value);
res.status(200).json(updatedCorrection);
} catch (error) {
// Check if the error message indicates "not found" to return a 404
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ message: error.message });
}
next(error);
}
});
router.put('/recipes/:id/status', async (req, res) => {
const recipeId = parseInt(req.params.id, 10);
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.' });
}
const updatedRecipe = await db.updateRecipeStatus(recipeId, status);
res.status(200).json(updatedRecipe);
});
router.post('/brands/:id/logo', upload.single('logoImage'), async (req, res) => {
const brandId = parseInt(req.params.id, 10);
if (!req.file) {
return res.status(400).json({ message: 'Logo image file is required.' });
}
const logoUrl = `/assets/${req.file.filename}`;
await db.updateBrandLogo(brandId, logoUrl);
logger.info(`Brand logo updated for brand ID: ${brandId}`, { brandId, logoUrl });
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
});
router.get('/unmatched-items', async (req, res) => {
const items = await db.getUnmatchedFlyerItems();
res.json(items);
});
router.put('/comments/:id/status', async (req, res) => {
const commentId = parseInt(req.params.id, 10);
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.' });
}
const updatedComment = await db.updateRecipeCommentStatus(commentId, status);
res.status(200).json(updatedComment);
});
router.get('/users', async (req, res) => {
const users = await db.getAllUsers();
res.json(users);
});
router.get('/activity-log', async (req, res) => {
const limit = parseInt(req.query.limit as string, 10) || 50;
const offset = parseInt(req.query.offset as string, 10) || 0;
const logs = await db.getActivityLog(limit, offset);
res.json(logs);
});
router.get('/users/:id', async (req, res) => {
const user = await db.findUserProfileById(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found.' });
}
res.json(user);
});
router.put('/users/:id', 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.updateUserRole(req.params.id, role);
res.json(updatedUser);
} catch (error) {
// Check if the error message indicates "not found" to return a 404
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ message: error.message });
}
logger.error(`Error updating user ${req.params.id}:`, { error });
next(error);
}
});
router.delete('/users/:id', async (req, res) => {
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.' });
}
await db.deleteUserById(req.params.id);
res.status(204).send();
});
/**
* 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) => {
const adminUser = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for daily deal check received from user: ${adminUser.user_id}`);
try {
// We call the function but don't wait for it to finish (no `await`).
// This allows the API to respond immediately while the job runs in the background.
runDailyDealCheck();
res.status(202).json({ message: 'Daily deal check job has been triggered successfully. It will run in the background.' });
} catch (error) {
logger.error('[Admin] Failed to trigger daily deal check job.', { error });
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, res, next: NextFunction) => {
const adminUser = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for analytics report generation received from user: ${adminUser.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('[Admin] Failed to enqueue analytics report job.', { error });
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', async (req, res) => {
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.' });
}
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${flyerId}`);
// Enqueue the cleanup job. The worker will handle the file deletion.
await cleanupQueue.add('cleanup-flyer-files', { flyerId });
res.status(202).json({ message: `File cleanup job for flyer ID ${flyerId} has been enqueued.` });
});
/**
* 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) => {
const adminUser = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for a failing job received from user: ${adminUser.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) {
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, res, next: NextFunction) => {
const adminUser = req.user as UserProfile;
logger.info(`[Admin] Manual trigger for geocode cache clear received from user: ${adminUser.user_id}`);
try {
const keysDeleted = await clearGeocodeCache();
res.status(200).json({ message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.` });
} catch (error) {
logger.error('[Admin] Failed to clear geocode cache.', { error });
next(error);
}
});
export default router;