Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
1353 lines
39 KiB
TypeScript
1353 lines
39 KiB
TypeScript
// src/routes/admin.routes.ts
|
|
import { Router, NextFunction, Request, Response } from 'express';
|
|
import passport, { isAdmin } from '../config/passport';
|
|
import { z } from 'zod';
|
|
|
|
import * as db from '../services/db/index.db';
|
|
import type { UserProfile } from '../types';
|
|
import { geocodingService } from '../services/geocodingService.server';
|
|
import { cacheService } from '../services/cacheService.server';
|
|
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
|
|
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
|
|
import { 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 { numericIdParam, uuidParamSchema, optionalNumeric } from '../utils/zodUtils';
|
|
// Removed: import { logger } from '../services/logger.server';
|
|
// All route handlers now use req.log (request-scoped logger) as per ADR-004
|
|
import { monitoringService } from '../services/monitoringService.server';
|
|
import { userService } from '../services/userService';
|
|
import { cleanupUploadedFile } from '../utils/fileUtils';
|
|
import { brandService } from '../services/brandService';
|
|
import { adminTriggerLimiter, adminUploadLimiter } from '../config/rateLimiters';
|
|
import { sendSuccess, sendNoContent } from '../utils/apiResponse';
|
|
|
|
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 ---
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/corrections:
|
|
* get:
|
|
* tags: [Admin]
|
|
* summary: Get suggested corrections
|
|
* description: Retrieve all suggested corrections for review. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: List of suggested corrections
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/SuccessResponse'
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.get('/corrections', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
|
try {
|
|
const corrections = await db.adminRepo.getSuggestedCorrections(req.log);
|
|
sendSuccess(res, corrections);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching suggested corrections');
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/review/flyers:
|
|
* get:
|
|
* tags: [Admin]
|
|
* summary: Get flyers for review
|
|
* description: Retrieve flyers pending admin review. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: List of flyers for review
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
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',
|
|
);
|
|
sendSuccess(res, flyers);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching flyers for review');
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/brands:
|
|
* get:
|
|
* tags: [Admin]
|
|
* summary: Get all brands
|
|
* description: Retrieve all brands. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: List of brands
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.get('/brands', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
|
try {
|
|
const brands = await db.flyerRepo.getAllBrands(req.log);
|
|
sendSuccess(res, brands);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching brands');
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/stats:
|
|
* get:
|
|
* tags: [Admin]
|
|
* summary: Get application stats
|
|
* description: Retrieve overall application statistics. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: Application statistics
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.get('/stats', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
|
try {
|
|
const stats = await db.adminRepo.getApplicationStats(req.log);
|
|
sendSuccess(res, stats);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching application stats');
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/stats/daily:
|
|
* get:
|
|
* tags: [Admin]
|
|
* summary: Get daily statistics
|
|
* description: Retrieve daily statistics for the last 30 days. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: Daily statistics for last 30 days
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.get('/stats/daily', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
|
try {
|
|
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(req.log);
|
|
sendSuccess(res, dailyStats);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching daily stats');
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/corrections/{id}/approve:
|
|
* post:
|
|
* tags: [Admin]
|
|
* summary: Approve a correction
|
|
* description: Approve a suggested correction. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Correction ID
|
|
* responses:
|
|
* 200:
|
|
* description: Correction approved successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: Correction not found
|
|
*/
|
|
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
|
|
sendSuccess(res, { message: 'Correction approved successfully.' });
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error approving correction');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/corrections/{id}/reject:
|
|
* post:
|
|
* tags: [Admin]
|
|
* summary: Reject a correction
|
|
* description: Reject a suggested correction. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Correction ID
|
|
* responses:
|
|
* 200:
|
|
* description: Correction rejected successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: Correction not found
|
|
*/
|
|
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
|
|
sendSuccess(res, { message: 'Correction rejected successfully.' });
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error rejecting correction');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/corrections/{id}:
|
|
* put:
|
|
* tags: [Admin]
|
|
* summary: Update a correction
|
|
* description: Update a suggested correction's value. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Correction ID
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - suggested_value
|
|
* properties:
|
|
* suggested_value:
|
|
* type: string
|
|
* description: New suggested value
|
|
* responses:
|
|
* 200:
|
|
* description: Correction updated successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: Correction not found
|
|
*/
|
|
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,
|
|
);
|
|
sendSuccess(res, updatedCorrection);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error updating suggested correction');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/recipes/{id}/status:
|
|
* put:
|
|
* tags: [Admin]
|
|
* summary: Update recipe status
|
|
* description: Update a recipe's publication status. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Recipe ID
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - status
|
|
* properties:
|
|
* status:
|
|
* type: string
|
|
* enum: [private, pending_review, public, rejected]
|
|
* responses:
|
|
* 200:
|
|
* description: Recipe status updated successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: Recipe not found
|
|
*/
|
|
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
|
|
sendSuccess(res, updatedRecipe);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error updating recipe status');
|
|
next(error); // Pass all errors to the central error handler
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/brands/{id}/logo:
|
|
* post:
|
|
* tags: [Admin]
|
|
* summary: Upload brand logo
|
|
* description: Upload or update a brand's logo image. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Brand ID
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* multipart/form-data:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - logoImage
|
|
* properties:
|
|
* logoImage:
|
|
* type: string
|
|
* format: binary
|
|
* description: Logo image file (max 2MB)
|
|
* responses:
|
|
* 200:
|
|
* description: Brand logo updated successfully
|
|
* 400:
|
|
* description: Invalid file or missing logo image
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: Brand not found
|
|
*/
|
|
router.post(
|
|
'/brands/:id/logo',
|
|
adminUploadLimiter,
|
|
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);
|
|
|
|
req.log.info(
|
|
{ brandId: params.id, logoUrl },
|
|
`Brand logo updated for brand ID: ${params.id}`,
|
|
);
|
|
sendSuccess(res, { 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);
|
|
req.log.error({ error }, 'Error updating brand logo');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/unmatched-items:
|
|
* get:
|
|
* tags: [Admin]
|
|
* summary: Get unmatched flyer items
|
|
* description: Retrieve flyer items that couldn't be matched to master items. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: List of unmatched flyer items
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.get(
|
|
'/unmatched-items',
|
|
validateRequest(emptySchema),
|
|
async (req, res, next: NextFunction) => {
|
|
try {
|
|
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
|
|
sendSuccess(res, items);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching unmatched items');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/recipes/{recipeId}:
|
|
* delete:
|
|
* tags: [Admin]
|
|
* summary: Delete a recipe
|
|
* description: Admin endpoint to delete any recipe. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: recipeId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Recipe ID
|
|
* responses:
|
|
* 204:
|
|
* description: Recipe deleted successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: Recipe not found
|
|
*/
|
|
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);
|
|
sendNoContent(res);
|
|
} catch (error: unknown) {
|
|
req.log.error({ error }, 'Error deleting recipe');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/flyers/{flyerId}:
|
|
* delete:
|
|
* tags: [Admin]
|
|
* summary: Delete a flyer
|
|
* description: Admin endpoint to delete a flyer and its items. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: flyerId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Flyer ID
|
|
* responses:
|
|
* 204:
|
|
* description: Flyer deleted successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: Flyer not found
|
|
*/
|
|
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);
|
|
sendNoContent(res);
|
|
} catch (error: unknown) {
|
|
req.log.error({ error }, 'Error deleting flyer');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/comments/{id}/status:
|
|
* put:
|
|
* tags: [Admin]
|
|
* summary: Update comment status
|
|
* description: Update a recipe comment's visibility status. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Comment ID
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - status
|
|
* properties:
|
|
* status:
|
|
* type: string
|
|
* enum: [visible, hidden, reported]
|
|
* responses:
|
|
* 200:
|
|
* description: Comment status updated successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: Comment not found
|
|
*/
|
|
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
|
|
sendSuccess(res, updatedComment);
|
|
} catch (error: unknown) {
|
|
req.log.error({ error }, 'Error updating comment status');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/users:
|
|
* get:
|
|
* tags: [Admin]
|
|
* summary: Get all users
|
|
* description: Retrieve a list of all users. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: List of all users
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.get('/users', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
|
|
try {
|
|
const users = await db.adminRepo.getAllUsers(req.log);
|
|
sendSuccess(res, users);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching users');
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/activity-log:
|
|
* get:
|
|
* tags: [Admin]
|
|
* summary: Get activity log
|
|
* description: Retrieve system activity log with pagination. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: query
|
|
* name: limit
|
|
* schema:
|
|
* type: integer
|
|
* default: 50
|
|
* description: Maximum number of entries to return
|
|
* - in: query
|
|
* name: offset
|
|
* schema:
|
|
* type: integer
|
|
* default: 0
|
|
* description: Number of entries to skip
|
|
* responses:
|
|
* 200:
|
|
* description: Activity log entries
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
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);
|
|
sendSuccess(res, logs);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching activity log');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/users/{id}:
|
|
* get:
|
|
* tags: [Admin]
|
|
* summary: Get user by ID
|
|
* description: Retrieve a specific user's profile. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* format: uuid
|
|
* description: User ID
|
|
* responses:
|
|
* 200:
|
|
* description: User profile
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: User not found
|
|
*/
|
|
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);
|
|
sendSuccess(res, user);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching user profile');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/users/{id}:
|
|
* put:
|
|
* tags: [Admin]
|
|
* summary: Update user role
|
|
* description: Update a user's role. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* format: uuid
|
|
* description: User ID
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - role
|
|
* properties:
|
|
* role:
|
|
* type: string
|
|
* enum: [user, admin]
|
|
* responses:
|
|
* 200:
|
|
* description: User role updated successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: User not found
|
|
*/
|
|
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);
|
|
sendSuccess(res, updatedUser);
|
|
} catch (error) {
|
|
req.log.error({ error }, `Error updating user ${params.id}:`);
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/users/{id}:
|
|
* delete:
|
|
* tags: [Admin]
|
|
* summary: Delete a user
|
|
* description: Delete a user account. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* format: uuid
|
|
* description: User ID
|
|
* responses:
|
|
* 204:
|
|
* description: User deleted successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: User not found
|
|
*/
|
|
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);
|
|
sendNoContent(res);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error deleting user');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/trigger/daily-deal-check:
|
|
* post:
|
|
* tags: [Admin]
|
|
* summary: Trigger daily deal check
|
|
* description: Manually trigger the daily deal check job. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 202:
|
|
* description: Job triggered successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.post(
|
|
'/trigger/daily-deal-check',
|
|
adminTriggerLimiter,
|
|
validateRequest(emptySchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const userProfile = req.user as UserProfile;
|
|
req.log.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();
|
|
sendSuccess(
|
|
res,
|
|
{
|
|
message:
|
|
'Daily deal check job has been triggered successfully. It will run in the background.',
|
|
},
|
|
202,
|
|
);
|
|
} catch (error) {
|
|
req.log.error({ error }, '[Admin] Failed to trigger daily deal check job.');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/trigger/analytics-report:
|
|
* post:
|
|
* tags: [Admin]
|
|
* summary: Trigger analytics report
|
|
* description: Manually enqueue a job to generate the daily analytics report. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 202:
|
|
* description: Job enqueued successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.post(
|
|
'/trigger/analytics-report',
|
|
adminTriggerLimiter,
|
|
validateRequest(emptySchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const userProfile = req.user as UserProfile;
|
|
req.log.info(
|
|
`[Admin] Manual trigger for analytics report generation received from user: ${userProfile.user.user_id}`,
|
|
);
|
|
|
|
try {
|
|
const jobId = await backgroundJobService.triggerAnalyticsReport();
|
|
sendSuccess(
|
|
res,
|
|
{
|
|
message: `Analytics report generation job has been enqueued successfully. Job ID: ${jobId}`,
|
|
},
|
|
202,
|
|
);
|
|
} catch (error) {
|
|
req.log.error({ error }, '[Admin] Failed to enqueue analytics report job.');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/flyers/{flyerId}/cleanup:
|
|
* post:
|
|
* tags: [Admin]
|
|
* summary: Trigger flyer file cleanup
|
|
* description: Enqueue a job to clean up a flyer's files. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: flyerId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Flyer ID
|
|
* responses:
|
|
* 202:
|
|
* description: Cleanup job enqueued successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: Flyer not found
|
|
*/
|
|
router.post(
|
|
'/flyers/:flyerId/cleanup',
|
|
adminTriggerLimiter,
|
|
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.
|
|
req.log.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 });
|
|
sendSuccess(
|
|
res,
|
|
{ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` },
|
|
202,
|
|
);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error enqueuing cleanup job');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/trigger/failing-job:
|
|
* post:
|
|
* tags: [Admin]
|
|
* summary: Trigger failing test job
|
|
* description: Enqueue a test job designed to fail for testing retry mechanisms. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 202:
|
|
* description: Failing test job enqueued successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.post(
|
|
'/trigger/failing-job',
|
|
adminTriggerLimiter,
|
|
validateRequest(emptySchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const userProfile = req.user as UserProfile;
|
|
req.log.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' });
|
|
sendSuccess(
|
|
res,
|
|
{ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` },
|
|
202,
|
|
);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error enqueuing failing job');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/system/clear-geocode-cache:
|
|
* post:
|
|
* tags: [Admin]
|
|
* summary: Clear geocode cache
|
|
* description: Clears the Redis cache for geocoded addresses. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: Cache cleared successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.post(
|
|
'/system/clear-geocode-cache',
|
|
adminTriggerLimiter,
|
|
validateRequest(emptySchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const userProfile = req.user as UserProfile;
|
|
req.log.info(
|
|
`[Admin] Manual trigger for geocode cache clear received from user: ${userProfile.user.user_id}`,
|
|
);
|
|
|
|
try {
|
|
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
|
|
sendSuccess(res, {
|
|
message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.`,
|
|
});
|
|
} catch (error) {
|
|
req.log.error({ error }, '[Admin] Failed to clear geocode cache.');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/workers/status:
|
|
* get:
|
|
* tags: [Admin]
|
|
* summary: Get worker statuses
|
|
* description: Get the current running status of all BullMQ workers. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: Worker status information
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.get(
|
|
'/workers/status',
|
|
validateRequest(emptySchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const workerStatuses = await monitoringService.getWorkerStatuses();
|
|
sendSuccess(res, workerStatuses);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching worker statuses');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/queues/status:
|
|
* get:
|
|
* tags: [Admin]
|
|
* summary: Get queue statuses
|
|
* description: Get job counts for all BullMQ queues. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: Queue status information
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.get(
|
|
'/queues/status',
|
|
validateRequest(emptySchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const queueStatuses = await monitoringService.getQueueStatuses();
|
|
sendSuccess(res, queueStatuses);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error fetching queue statuses');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/jobs/{queueName}/{jobId}/retry:
|
|
* post:
|
|
* tags: [Admin]
|
|
* summary: Retry a failed job
|
|
* description: Retries a specific failed job in a queue. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: queueName
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* enum: [flyer-processing, email-sending, analytics-reporting, file-cleanup, weekly-analytics-reporting]
|
|
* description: Queue name
|
|
* - in: path
|
|
* name: jobId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* description: Job ID
|
|
* responses:
|
|
* 200:
|
|
* description: Job marked for retry successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
* 404:
|
|
* description: Job not found
|
|
*/
|
|
router.post(
|
|
'/jobs/:queueName/:jobId/retry',
|
|
adminTriggerLimiter,
|
|
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);
|
|
sendSuccess(res, { message: `Job ${jobId} has been successfully marked for retry.` });
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error retrying job');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/trigger/weekly-analytics:
|
|
* post:
|
|
* tags: [Admin]
|
|
* summary: Trigger weekly analytics
|
|
* description: Manually trigger the weekly analytics report job. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 202:
|
|
* description: Job enqueued successfully
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.post(
|
|
'/trigger/weekly-analytics',
|
|
adminTriggerLimiter,
|
|
validateRequest(emptySchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const userProfile = req.user as UserProfile; // This was a duplicate, fixed.
|
|
req.log.info(
|
|
`[Admin] Manual trigger for weekly analytics report received from user: ${userProfile.user.user_id}`,
|
|
);
|
|
|
|
try {
|
|
const jobId = await backgroundJobService.triggerWeeklyAnalyticsReport();
|
|
sendSuccess(res, { message: 'Successfully enqueued weekly analytics job.', jobId }, 202);
|
|
} catch (error) {
|
|
req.log.error({ error }, 'Error enqueuing weekly analytics job');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /admin/system/clear-cache:
|
|
* post:
|
|
* tags: [Admin]
|
|
* summary: Clear application cache
|
|
* description: Clears cached flyers, brands, and stats data from Redis. Requires admin role.
|
|
* security:
|
|
* - bearerAuth: []
|
|
* responses:
|
|
* 200:
|
|
* description: Cache cleared successfully with details
|
|
* 401:
|
|
* description: Unauthorized
|
|
* 403:
|
|
* description: Forbidden - admin role required
|
|
*/
|
|
router.post(
|
|
'/system/clear-cache',
|
|
adminTriggerLimiter,
|
|
validateRequest(emptySchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
const userProfile = req.user as UserProfile;
|
|
req.log.info(`[Admin] Manual cache clear received from user: ${userProfile.user.user_id}`);
|
|
|
|
try {
|
|
const [flyersDeleted, brandsDeleted, statsDeleted] = await Promise.all([
|
|
cacheService.invalidateFlyers(req.log),
|
|
cacheService.invalidateBrands(req.log),
|
|
cacheService.invalidateStats(req.log),
|
|
]);
|
|
|
|
const totalDeleted = flyersDeleted + brandsDeleted + statsDeleted;
|
|
sendSuccess(res, {
|
|
message: `Successfully cleared the application cache. ${totalDeleted} keys were removed.`,
|
|
details: {
|
|
flyers: flyersDeleted,
|
|
brands: brandsDeleted,
|
|
stats: statsDeleted,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
req.log.error({ error }, '[Admin] Failed to clear application cache.');
|
|
next(error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/* Catches errors from multer (e.g., file size, file filter) */
|
|
router.use(handleMulterError);
|
|
|
|
export default router;
|