Some checks are pending
Deploy to Test Environment / deploy-to-test (push) Has started running
- Refactored AIService to integrate with the latest GoogleGenAI SDK, updating the generateContent method signature and response handling. - Adjusted error handling and logging for improved clarity and consistency. - Enhanced mock implementations in tests to align with the new SDK structure. refactor: Modify Admin DB service to use Profile type - Updated AdminRepository to replace User type with Profile in relevant methods. - Enhanced test cases to utilize mock factories for creating Profile and AdminUserView objects. fix: Improve error handling in BudgetRepository - Implemented type-safe checks for PostgreSQL error codes to enhance error handling in createBudget method. test: Refactor Deals DB tests for type safety - Updated DealsRepository tests to use Pool type for mock instances, ensuring type safety. chore: Add new mock factories for testing - Introduced mock factories for UserWithPasswordHash, Profile, WatchedItemDeal, LeaderboardUser, and UnmatchedFlyerItem to streamline test data creation. style: Clean up queue service tests - Refactored queue service tests to improve readability and maintainability, including better handling of mock worker instances. docs: Update types to include UserWithPasswordHash - Added UserWithPasswordHash interface to types for better clarity on user authentication data structure. chore: Remove deprecated Google AI SDK references - Updated code and documentation to reflect the migration to the new Google Generative AI SDK, removing references to the deprecated SDK.
516 lines
20 KiB
TypeScript
516 lines
20 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';// --- Zod Schemas for Admin Routes (as per ADR-003) ---
|
|
import { z } from 'zod';
|
|
|
|
import * as db from '../services/db/index.db';
|
|
import { logger } from '../services/logger.server';
|
|
import { UserProfile } from '../types';
|
|
import { geocodingService } from '../services/geocodingService.server';
|
|
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
|
|
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, flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker } from '../services/queueService.server'; // Import your queues
|
|
import { getSimpleWeekAndYear } from '../utils/dateUtils';
|
|
|
|
const uuidParamSchema = (key: string) => z.object({
|
|
params: z.object({ [key]: z.string().uuid() }),
|
|
});
|
|
|
|
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() }),
|
|
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 jobRetrySchema = z.object({
|
|
params: z.object({ queueName: z.string(), jobId: z.string() }),
|
|
});
|
|
|
|
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),
|
|
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) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.get('/brands', async (req, res, next: NextFunction) => {
|
|
try {
|
|
const brands = await db.flyerRepo.getAllBrands(req.log);
|
|
res.json(brands);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.get('/stats', async (req, res, next: NextFunction) => {
|
|
try {
|
|
const stats = await db.adminRepo.getApplicationStats(req.log);
|
|
res.json(stats);
|
|
} catch (error) {
|
|
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) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.post('/corrections/:id/approve', validateRequest(numericIdParamSchema('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 numericIdParamSchema>>;
|
|
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) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.post('/corrections/:id/reject', validateRequest(numericIdParamSchema('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 numericIdParamSchema>>;
|
|
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) {
|
|
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) {
|
|
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) {
|
|
next(error); // Pass all errors to the central error handler
|
|
}
|
|
});
|
|
|
|
router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('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<ReturnType<typeof numericIdParamSchema>>;
|
|
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 = `/assets/${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) {
|
|
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) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
|
|
*/
|
|
router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipeId')), async (req: Request, res: Response, next: NextFunction) => {
|
|
// Define schema locally to simplify type inference
|
|
const schema = numericIdParamSchema('recipeId');
|
|
const adminUser = req.user as UserProfile;
|
|
const { params } = req as unknown as z.infer<typeof schema>;
|
|
try {
|
|
// The isAdmin flag bypasses the ownership check in the repository method.
|
|
await db.recipeRepo.deleteRecipe(params.recipeId, adminUser.user_id, true, req.log);
|
|
res.status(204).send();
|
|
} catch (error: unknown) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/admin/flyers/:flyerId - Admin endpoint to delete a flyer and its items.
|
|
*/
|
|
router.delete('/flyers/:flyerId', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
|
|
// Define schema locally to simplify type inference
|
|
const schema = numericIdParamSchema('flyerId');
|
|
const { params } = req as unknown as z.infer<typeof schema>;
|
|
try {
|
|
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
|
|
res.status(204).send();
|
|
} catch (error: unknown) {
|
|
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) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.get('/users', async (req, res, next: NextFunction) => {
|
|
try {
|
|
const users = await db.adminRepo.getAllUsers(req.log);
|
|
res.json(users);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.get('/activity-log', validateRequest(activityLogSchema), async (req: Request, res: Response, next: NextFunction) => {
|
|
// Apply ADR-003 pattern for type safety
|
|
const { query } = req as unknown as z.infer<typeof activityLogSchema>;
|
|
try {
|
|
const logs = await db.adminRepo.getActivityLog(query.limit, query.offset, req.log);
|
|
res.json(logs);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.get('/users/:id', validateRequest(uuidParamSchema('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 uuidParamSchema>>;
|
|
try {
|
|
const user = await db.userRepo.findUserProfileById(params.id, req.log);
|
|
res.json(user);
|
|
} catch (error) {
|
|
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')), async (req: Request, res: Response, next: NextFunction) => {
|
|
const adminUser = req.user as UserProfile;
|
|
// Apply ADR-003 pattern for type safety
|
|
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
|
|
try {
|
|
if (adminUser.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) {
|
|
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 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 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 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({ 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(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
|
|
// Define schema locally to simplify type inference
|
|
const schema = numericIdParamSchema('flyerId');
|
|
const adminUser = req.user as UserProfile;
|
|
const { params } = req as unknown as z.infer<typeof schema>;
|
|
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.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) {
|
|
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 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: Request, res: Response, 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 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) {
|
|
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 adminUser = req.user as UserProfile;
|
|
const { params: { queueName, jobId } } = req as unknown as z.infer<typeof jobRetrySchema>;
|
|
|
|
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}.`);
|
|
|
|
await job.retry();
|
|
logger.info(`[Admin] User ${adminUser.user_id} manually retried job ${jobId} in queue ${queueName}.`);
|
|
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
|
|
} catch (error) {
|
|
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 adminUser = req.user as UserProfile;
|
|
logger.info(`[Admin] Manual trigger for weekly analytics report received from user: ${adminUser.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) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
export default router; |