Files
flyer-crawler.projectium.com/src/controllers/admin.controller.ts
Torben Sorensen 2d2cd52011
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
Massive Dependency Modernization Project
2026-02-13 00:34:22 -08:00

1420 lines
45 KiB
TypeScript

// src/controllers/admin.controller.ts
// ============================================================================
// ADMIN CONTROLLER
// ============================================================================
// Provides administrative endpoints for managing the Flyer Crawler application.
// All endpoints require admin role authentication via @Security('bearerAuth', ['admin']).
//
// Endpoint Categories:
// - Corrections Management: Review, approve, reject, update suggested corrections
// - User Management: List, view, update role, delete users
// - Content Management: Review flyers, manage recipes, manage comments
// - Brand Management: Update brand logos (file upload)
// - Queue/Worker Monitoring: View queue and worker status, retry failed jobs
// - System Operations: Clear caches, trigger background jobs
// - Feature Flags: View current feature flag states
// - WebSocket Stats: View real-time connection statistics
//
// Note: Bull Board UI is mounted separately in server.ts due to its special
// routing requirements. This controller handles the JSON API endpoints only.
//
// Implements ADR-028 (API Response Format) via BaseController.
// ============================================================================
import {
Get,
Put,
Post,
Delete,
Route,
Tags,
Security,
Path,
Query,
Body,
Request,
SuccessResponse,
Response,
Middlewares,
} from 'tsoa';
import type { Request as ExpressRequest } from 'express';
import { BaseController } from './base.controller';
import type { SuccessResponse as SuccessResponseType, ErrorResponse } from './types';
import * as db from '../services/db/index.db';
import { backgroundJobService } from '../services/backgroundJobService';
import { monitoringService } from '../services/monitoringService.server';
import { geocodingService } from '../services/geocodingService.server';
import { cacheService } from '../services/cacheService.server';
import { brandService } from '../services/brandService';
import { userService } from '../services/userService';
import { getFeatureFlags } from '../services/featureFlags.server';
import { cleanupQueue, analyticsQueue } from '../services/queueService.server';
import type { UserProfile } from '../types';
import type { MessageResponse } from '../dtos/common.dto';
import { adminTriggerLimiter, adminUploadLimiter } from '../config/rateLimiters';
import { ValidationError } from '../services/db/errors.db';
import { cleanupUploadedFile } from '../utils/fileUtils';
// ============================================================================
// DTO TYPES FOR OPENAPI
// ============================================================================
// These Data Transfer Object types are tsoa-compatible versions of the
// domain types. They provide clear API contracts for OpenAPI generation.
// ============================================================================
// --- Corrections DTOs ---
/**
* Suggested correction data returned by admin endpoints.
*/
interface SuggestedCorrectionDto {
/** Unique identifier for the correction */
readonly suggested_correction_id: number;
/** ID of the flyer item being corrected */
readonly flyer_item_id: number;
/** User who submitted the correction */
readonly user_id: string;
/** Type of correction (e.g., 'master_item', 'category') */
correction_type: string;
/** The suggested new value */
suggested_value: string;
/** Current status of the correction */
status: 'pending' | 'approved' | 'rejected';
/** When the correction was last updated */
readonly updated_at: string;
/** When the correction was created */
readonly created_at: string;
/** Name of the flyer item (joined data) */
flyer_item_name?: string;
/** Price display of the flyer item (joined data) */
flyer_item_price_display?: string;
/** Email of the user who submitted (joined data) */
user_email?: string;
}
/**
* Request body for updating a correction's suggested value.
*/
interface UpdateCorrectionRequest {
/**
* The new suggested value for the correction.
* @minLength 1
*/
suggested_value: string;
}
// --- User Management DTOs ---
/**
* User data as seen by admins.
*/
interface AdminUserDto {
/** User's unique identifier (UUID) */
readonly user_id: string;
/** User's email address */
email: string;
/** User's role */
role: 'admin' | 'user';
/** User's full name */
full_name: string | null;
/** URL to user's avatar */
avatar_url: string | null;
/** Account creation timestamp */
readonly created_at: string;
}
/**
* Response for listing all users.
*/
interface UsersListResponse {
/** Array of user data */
users: AdminUserDto[];
/** Total number of users in the system */
total: number;
}
/**
* Request body for updating a user's role.
*/
interface UpdateUserRoleRequest {
/** The new role to assign */
role: 'user' | 'admin';
}
/**
* Profile data returned when updating a user.
*/
interface ProfileDto {
/** User's full name */
full_name?: string | null;
/** URL to user's avatar */
avatar_url?: string | null;
/** Associated address ID */
address_id?: number | null;
/** User's points balance */
readonly points: number;
/** User's role */
readonly role: 'admin' | 'user';
/** User preferences */
preferences?: {
darkMode?: boolean;
unitSystem?: 'metric' | 'imperial';
} | null;
/** Timestamp of creation */
readonly created_at: string;
/** Timestamp of last update */
readonly updated_at: string;
}
// --- Recipe DTOs ---
/**
* Recipe data returned by admin endpoints.
* Named AdminRecipeDto to avoid conflict with recipe.controller.ts RecipeDto.
*/
interface AdminRecipeDto {
/** Unique recipe identifier */
readonly recipe_id: number;
/** Owner's user ID */
readonly user_id?: string | null;
/** Recipe name */
name: string;
/** Recipe description */
description?: string | null;
/** Cooking instructions */
instructions?: string | null;
/** Preparation time in minutes */
prep_time_minutes?: number | null;
/** Cooking time in minutes */
cook_time_minutes?: number | null;
/** Number of servings */
servings?: number | null;
/** URL to recipe photo */
photo_url?: string | null;
/** Average rating */
readonly avg_rating: number;
/** Publication status */
status: 'private' | 'pending_review' | 'public' | 'rejected';
/** Number of ratings */
readonly rating_count: number;
/** Timestamp of creation */
readonly created_at: string;
/** Timestamp of last update */
readonly updated_at: string;
}
/**
* Request body for updating recipe status.
*/
interface UpdateRecipeStatusRequest {
/** The new status for the recipe */
status: 'private' | 'pending_review' | 'public' | 'rejected';
}
// --- Comment DTOs ---
/**
* Recipe comment data returned by admin endpoints.
* Named AdminRecipeCommentDto to avoid conflict with recipe.controller.ts.
*/
interface AdminRecipeCommentDto {
/** Unique comment identifier */
readonly recipe_comment_id: number;
/** Parent recipe ID */
readonly recipe_id: number;
/** Author's user ID */
readonly user_id: string;
/** Parent comment ID for replies */
readonly parent_comment_id?: number | null;
/** Comment content */
content: string;
/** Visibility status */
status: 'visible' | 'hidden' | 'reported';
/** Timestamp of creation */
readonly created_at: string;
/** Timestamp of last update */
readonly updated_at: string;
}
/**
* Request body for updating comment status.
*/
interface UpdateCommentStatusRequest {
/** The new status for the comment */
status: 'visible' | 'hidden' | 'reported';
}
// --- Flyer DTOs ---
/**
* Flyer data returned by admin review endpoints.
*/
interface AdminFlyerDto {
/** Unique flyer identifier */
readonly flyer_id: number;
/** Original filename */
file_name: string;
/** URL to flyer image */
image_url: string;
/** URL to 64x64 icon */
icon_url: string;
/** Processing status */
status: 'processed' | 'needs_review' | 'archived';
/** Number of items in the flyer */
item_count: number;
/** Start date for deals */
valid_from?: string | null;
/** End date for deals */
valid_to?: string | null;
/** Timestamp of creation */
readonly created_at: string;
/** Timestamp of last update */
readonly updated_at: string;
}
// --- Unmatched Items DTOs ---
/**
* Unmatched flyer item data for admin review.
*/
interface UnmatchedFlyerItemDto {
/** Unique identifier */
readonly unmatched_flyer_item_id: number;
/** Current status */
status: 'pending' | 'resolved' | 'ignored';
/** ID of the flyer item */
readonly flyer_item_id: number;
/** Item name from the flyer */
flyer_item_name: string;
/** Price display text */
price_display: string;
/** Parent flyer ID */
flyer_id: number;
/** Store name */
store_name: string;
/** Timestamp of creation */
readonly created_at: string;
/** Timestamp of last update */
readonly updated_at: string;
}
// --- Brand DTOs ---
/**
* Brand data returned by admin endpoints.
*/
interface BrandDto {
/** Unique brand identifier */
readonly brand_id: number;
/** Brand name */
name: string;
/** URL to brand logo */
logo_url?: string | null;
/** Associated store ID */
readonly store_id?: number | null;
/** Timestamp of creation */
readonly created_at: string;
/** Timestamp of last update */
readonly updated_at: string;
}
/**
* Response after uploading a brand logo.
*/
interface BrandLogoResponse {
/** Success message */
message: string;
/** URL to the uploaded logo */
logoUrl: string;
}
// --- Stats DTOs ---
/**
* Application-wide statistics.
*/
interface ApplicationStatsDto {
/** Total number of flyers */
flyerCount: number;
/** Total number of users */
userCount: number;
/** Total number of flyer items */
flyerItemCount: number;
/** Total number of stores */
storeCount: number;
/** Number of pending corrections */
pendingCorrectionCount: number;
/** Total number of recipes */
recipeCount: number;
}
/**
* Daily statistics for the dashboard.
*/
interface DailyStatsDto {
/** Date in YYYY-MM-DD format */
date: string;
/** Number of new users registered */
new_users: number;
/** Number of new flyers uploaded */
new_flyers: number;
}
// --- Activity Log DTOs ---
/**
* Activity log entry.
*/
interface ActivityLogItemDto {
/** Unique log entry ID */
readonly activity_log_id: number;
/** User who performed the action (null for system actions) */
readonly user_id: string | null;
/** Type of action */
action: string;
/** Human-readable description */
display_text: string;
/** Icon identifier */
icon?: string | null;
/** Timestamp of the activity */
readonly created_at: string;
/** User's name (joined data) */
user_full_name?: string;
/** User's avatar URL (joined data) */
user_avatar_url?: string;
}
// --- Queue/Worker DTOs ---
/**
* Worker status information.
*/
interface WorkerStatusDto {
/** Worker name */
name: string;
/** Whether the worker is currently running */
isRunning: boolean;
}
/**
* Queue status with job counts.
*/
interface QueueStatusDto {
/** Queue name */
name: string;
/** Job counts by state */
counts: {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
paused: number;
};
}
// RetryJobRequest is not needed - queueName and jobId come from path parameters
// --- System DTOs ---
/**
* Response for cache clear operations.
*/
interface CacheClearResponse {
/** Success message */
message: string;
/** Details of what was cleared */
details?: {
flyers?: number;
brands?: number;
stats?: number;
};
}
/**
* Response for triggered background jobs.
*/
interface JobTriggeredResponse {
/** Success message */
message: string;
/** Job ID if available */
jobId?: string;
}
// --- Feature Flags DTOs ---
/**
* Feature flags response.
*/
interface FeatureFlagsResponse {
/** Map of feature flag names to their enabled state */
flags: Record<string, boolean>;
}
// --- WebSocket DTOs ---
/**
* WebSocket connection statistics.
*/
interface WebSocketStatsDto {
/** Number of unique users with active connections */
totalUsers: number;
/** Total number of active WebSocket connections */
totalConnections: number;
}
// --- User Profile DTOs ---
/**
* User profile data returned by admin user view endpoint.
* Named AdminViewUserProfileDto to avoid conflict with common.dto.ts UserProfileDto.
*/
interface AdminViewUserProfileDto {
/** User's full name */
full_name?: string | null;
/** URL to user's avatar */
avatar_url?: string | null;
/** Associated address ID */
address_id?: number | null;
/** User's points balance */
readonly points: number;
/** User's role */
readonly role: 'admin' | 'user';
/** Timestamp of creation */
readonly created_at: string;
/** Timestamp of last update */
readonly updated_at: string;
/** Nested user data */
user: {
user_id: string;
email: string;
created_at: string;
updated_at: string;
};
}
// Note: MessageResponse is imported from common.dto.ts below
// ============================================================================
// ADMIN CONTROLLER
// ============================================================================
/**
* Administrative controller for managing the Flyer Crawler application.
*
* All endpoints in this controller require admin role authentication.
* The Bull Board UI for queue management is mounted separately at
* /api/v1/admin/jobs and is not part of this controller.
*
* Rate limiting is applied to sensitive operations:
* - adminTriggerLimiter: For triggering background jobs (30 req/15 min)
* - adminUploadLimiter: For file uploads (20 req/15 min)
*/
@Route('admin')
@Tags('Admin')
@Security('bearerAuth', ['admin'])
export class AdminController extends BaseController {
// ==========================================================================
// CORRECTIONS MANAGEMENT
// ==========================================================================
/**
* Get all pending corrections.
*
* Retrieves all suggested corrections that are pending review.
* Includes context about the flyer item and the user who submitted.
*
* @summary Get pending corrections
* @param request Express request for logging
* @returns List of pending suggested corrections
*/
@Get('corrections')
@SuccessResponse(200, 'List of pending corrections')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getCorrections(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<SuggestedCorrectionDto[]>> {
const corrections = await db.adminRepo.getSuggestedCorrections(request.log);
return this.success(corrections as SuggestedCorrectionDto[]);
}
/**
* Approve a correction.
*
* Approves a pending correction and applies the change to the flyer item.
* This operation is transactional.
*
* @summary Approve a correction
* @param id Correction ID
* @param request Express request for logging
* @returns Success message
*/
@Post('corrections/{id}/approve')
@SuccessResponse(200, 'Correction approved')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'Correction not found')
public async approveCorrection(
@Path() id: number,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<MessageResponse>> {
await db.adminRepo.approveCorrection(id, request.log);
return this.success({ message: 'Correction approved successfully.' });
}
/**
* Reject a correction.
*
* Rejects a pending correction without applying any changes.
*
* @summary Reject a correction
* @param id Correction ID
* @param request Express request for logging
* @returns Success message
*/
@Post('corrections/{id}/reject')
@SuccessResponse(200, 'Correction rejected')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'Correction not found')
public async rejectCorrection(
@Path() id: number,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<MessageResponse>> {
await db.adminRepo.rejectCorrection(id, request.log);
return this.success({ message: 'Correction rejected successfully.' });
}
/**
* Update a correction's suggested value.
*
* Allows an admin to modify the suggested value before approving.
*
* @summary Update correction value
* @param id Correction ID
* @param body New suggested value
* @param request Express request for logging
* @returns The updated correction
*/
@Put('corrections/{id}')
@SuccessResponse(200, 'Correction updated')
@Response<ErrorResponse>(400, 'Invalid suggested value')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'Correction not found')
public async updateCorrection(
@Path() id: number,
@Body() body: UpdateCorrectionRequest,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<SuggestedCorrectionDto>> {
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(
id,
body.suggested_value,
request.log,
);
return this.success(updatedCorrection as SuggestedCorrectionDto);
}
// ==========================================================================
// USER MANAGEMENT
// ==========================================================================
/**
* Get all users.
*
* Retrieves a paginated list of all users with their profiles.
*
* @summary List all users
* @param request Express request for logging
* @param limit Maximum users to return (1-100)
* @param offset Number of users to skip
* @returns List of users with total count
*/
@Get('users')
@SuccessResponse(200, 'List of users')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getUsers(
@Request() request: ExpressRequest,
@Query() limit?: number,
@Query() offset?: number,
): Promise<SuccessResponseType<UsersListResponse>> {
// Normalize pagination
const normalizedLimit =
limit !== undefined ? Math.min(100, Math.max(1, Math.floor(limit))) : undefined;
const normalizedOffset = offset !== undefined ? Math.max(0, Math.floor(offset)) : 0;
const result = await db.adminRepo.getAllUsers(request.log, normalizedLimit, normalizedOffset);
return this.success({
users: result.users as AdminUserDto[],
total: result.total,
});
}
/**
* Get user by ID.
*
* Retrieves a specific user's profile.
*
* @summary Get user profile
* @param id User ID (UUID)
* @param request Express request for logging
* @returns User profile data
*/
@Get('users/{id}')
@SuccessResponse(200, 'User profile')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'User not found')
public async getUserById(
@Path() id: string,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<AdminViewUserProfileDto>> {
const user = await db.userRepo.findUserProfileById(id, request.log);
return this.success(user as unknown as AdminViewUserProfileDto);
}
/**
* Update user role.
*
* Changes a user's role between 'user' and 'admin'.
*
* @summary Update user role
* @param id User ID (UUID)
* @param body New role
* @param request Express request for logging
* @returns Updated profile
*/
@Put('users/{id}')
@SuccessResponse(200, 'Role updated')
@Response<ErrorResponse>(400, 'Invalid role')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'User not found')
public async updateUserRole(
@Path() id: string,
@Body() body: UpdateUserRoleRequest,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<ProfileDto>> {
const updatedUser = await db.adminRepo.updateUserRole(id, body.role, request.log);
return this.success(updatedUser as ProfileDto);
}
/**
* Delete a user.
*
* Permanently deletes a user account. Admins cannot delete their own account.
*
* @summary Delete user
* @param id User ID (UUID)
* @param request Express request for logging
*/
@Delete('users/{id}')
@SuccessResponse(204, 'User deleted')
@Response<ErrorResponse>(400, 'Cannot delete own account')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'User not found')
public async deleteUser(@Path() id: string, @Request() request: ExpressRequest): Promise<void> {
const userProfile = request.user as UserProfile;
await userService.deleteUserAsAdmin(userProfile.user.user_id, id, request.log);
this.noContent();
}
// ==========================================================================
// CONTENT MANAGEMENT - RECIPES
// ==========================================================================
/**
* Update recipe status.
*
* Changes a recipe's publication status for moderation purposes.
*
* @summary Update recipe status
* @param id Recipe ID
* @param body New status
* @param request Express request for logging
* @returns Updated recipe
*/
@Put('recipes/{id}/status')
@SuccessResponse(200, 'Recipe status updated')
@Response<ErrorResponse>(400, 'Invalid status')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'Recipe not found')
public async updateRecipeStatus(
@Path() id: number,
@Body() body: UpdateRecipeStatusRequest,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<AdminRecipeDto>> {
const updatedRecipe = await db.adminRepo.updateRecipeStatus(id, body.status, request.log);
return this.success(updatedRecipe as AdminRecipeDto);
}
/**
* Delete a recipe.
*
* Permanently deletes any recipe regardless of ownership.
*
* @summary Delete recipe (Admin)
* @param recipeId Recipe ID
* @param request Express request for logging
*/
@Delete('recipes/{recipeId}')
@SuccessResponse(204, 'Recipe deleted')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'Recipe not found')
public async deleteRecipe(
@Path() recipeId: number,
@Request() request: ExpressRequest,
): Promise<void> {
const userProfile = request.user as UserProfile;
// Pass isAdmin=true to bypass ownership check
await db.recipeRepo.deleteRecipe(recipeId, userProfile.user.user_id, true, request.log);
this.noContent();
}
// ==========================================================================
// CONTENT MANAGEMENT - COMMENTS
// ==========================================================================
/**
* Update comment status.
*
* Changes a recipe comment's visibility status for moderation.
*
* @summary Update comment status
* @param id Comment ID
* @param body New status
* @param request Express request for logging
* @returns Updated comment
*/
@Put('comments/{id}/status')
@SuccessResponse(200, 'Comment status updated')
@Response<ErrorResponse>(400, 'Invalid status')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'Comment not found')
public async updateCommentStatus(
@Path() id: number,
@Body() body: UpdateCommentStatusRequest,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<AdminRecipeCommentDto>> {
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(
id,
body.status,
request.log,
);
return this.success(updatedComment as AdminRecipeCommentDto);
}
// ==========================================================================
// CONTENT MANAGEMENT - FLYERS
// ==========================================================================
/**
* Get flyers needing review.
*
* Retrieves all flyers with 'needs_review' status.
*
* @summary Get flyers for review
* @param request Express request for logging
* @returns List of flyers needing review
*/
@Get('review/flyers')
@SuccessResponse(200, 'List of flyers for review')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getFlyersForReview(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<AdminFlyerDto[]>> {
request.log.debug('Fetching flyers for review via adminRepo');
const flyers = await db.adminRepo.getFlyersForReview(request.log);
request.log.info(
{ count: Array.isArray(flyers) ? flyers.length : 'unknown' },
'Successfully fetched flyers for review',
);
return this.success(flyers as unknown as AdminFlyerDto[]);
}
/**
* Delete a flyer.
*
* Permanently deletes a flyer and all its items.
*
* @summary Delete flyer
* @param flyerId Flyer ID
* @param request Express request for logging
*/
@Delete('flyers/{flyerId}')
@SuccessResponse(204, 'Flyer deleted')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'Flyer not found')
public async deleteFlyer(
@Path() flyerId: number,
@Request() request: ExpressRequest,
): Promise<void> {
await db.flyerRepo.deleteFlyer(flyerId, request.log);
this.noContent();
}
/**
* Trigger flyer file cleanup.
*
* Enqueues a background job to clean up files associated with a flyer.
*
* @summary Trigger flyer cleanup
* @param flyerId Flyer ID
* @param request Express request for logging
* @returns Job enqueued message
*/
@Post('flyers/{flyerId}/cleanup')
@Middlewares(adminTriggerLimiter)
@SuccessResponse(202, 'Cleanup job enqueued')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(429, 'Too many requests')
public async triggerFlyerCleanup(
@Path() flyerId: number,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<JobTriggeredResponse>> {
const userProfile = request.user as UserProfile;
request.log.info(
`[Admin] Manual trigger for flyer file cleanup received from user: ${userProfile.user.user_id} for flyer ID: ${flyerId}`,
);
await cleanupQueue.add('cleanup-flyer-files', { flyerId });
this.setStatus(202);
return this.success({ message: `File cleanup job for flyer ID ${flyerId} has been enqueued.` });
}
// ==========================================================================
// CONTENT MANAGEMENT - UNMATCHED ITEMS
// ==========================================================================
/**
* Get unmatched flyer items.
*
* Retrieves flyer items that couldn't be matched to master items.
*
* @summary Get unmatched items
* @param request Express request for logging
* @returns List of unmatched items
*/
@Get('unmatched-items')
@SuccessResponse(200, 'List of unmatched items')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getUnmatchedItems(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<UnmatchedFlyerItemDto[]>> {
const items = await db.adminRepo.getUnmatchedFlyerItems(request.log);
return this.success(items as UnmatchedFlyerItemDto[]);
}
// ==========================================================================
// BRAND MANAGEMENT
// ==========================================================================
/**
* Get all brands.
*
* Retrieves a list of all brands in the system.
*
* @summary Get all brands
* @param request Express request for logging
* @returns List of brands
*/
@Get('brands')
@SuccessResponse(200, 'List of brands')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getBrands(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<BrandDto[]>> {
const brands = await db.flyerRepo.getAllBrands(request.log);
return this.success(brands as BrandDto[]);
}
/**
* Upload brand logo.
*
* Uploads or updates a brand's logo image.
* Accepts JPEG, PNG, GIF, or WebP images up to 2MB.
*
* Note: File upload handling requires multer middleware to be configured
* in the route registration. The file is accessed via request.file.
*
* @summary Upload brand logo
* @param id Brand ID
* @param request Express request with uploaded file
* @returns Success message with logo URL
*/
@Post('brands/{id}/logo')
@Middlewares(adminUploadLimiter)
@SuccessResponse(200, 'Logo uploaded')
@Response<ErrorResponse>(400, 'Invalid file or missing logo')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'Brand not found')
@Response<ErrorResponse>(429, 'Too many requests')
public async uploadBrandLogo(
@Path() id: number,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<BrandLogoResponse>> {
// Get file from request (uploaded by multer middleware)
const logoImage = request.file as Express.Multer.File | undefined;
try {
// Validate file was uploaded
if (!logoImage) {
throw new ValidationError([], 'Logo image file is missing.');
}
const logoUrl = await brandService.updateBrandLogo(id, logoImage, request.log);
request.log.info({ brandId: id, logoUrl }, `Brand logo updated for brand ID: ${id}`);
return this.success({ message: 'Brand logo updated successfully.', logoUrl });
} catch (error) {
// Clean up the uploaded file if an error occurred
await cleanupUploadedFile(logoImage);
throw error;
}
}
// ==========================================================================
// STATISTICS
// ==========================================================================
/**
* Get application statistics.
*
* Retrieves overall application statistics for the admin dashboard.
*
* @summary Get application stats
* @param request Express request for logging
* @returns Application statistics
*/
@Get('stats')
@SuccessResponse(200, 'Application statistics')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getStats(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<ApplicationStatsDto>> {
const stats = await db.adminRepo.getApplicationStats(request.log);
return this.success(stats);
}
/**
* Get daily statistics.
*
* Retrieves daily user registration and flyer upload statistics
* for the last 30 days.
*
* @summary Get daily stats
* @param request Express request for logging
* @returns Daily statistics array
*/
@Get('stats/daily')
@SuccessResponse(200, 'Daily statistics')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getDailyStats(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<DailyStatsDto[]>> {
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(request.log);
return this.success(dailyStats);
}
// ==========================================================================
// ACTIVITY LOG
// ==========================================================================
/**
* Get activity log.
*
* Retrieves recent system activity with pagination.
*
* @summary Get activity log
* @param request Express request for logging
* @param limit Maximum entries to return (default: 50)
* @param offset Number of entries to skip (default: 0)
* @returns Activity log entries
*/
@Get('activity-log')
@SuccessResponse(200, 'Activity log entries')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getActivityLog(
@Request() request: ExpressRequest,
@Query() limit?: number,
@Query() offset?: number,
): Promise<SuccessResponseType<ActivityLogItemDto[]>> {
const normalizedLimit = limit ?? 50;
const normalizedOffset = offset ?? 0;
const logs = await db.adminRepo.getActivityLog(normalizedLimit, normalizedOffset, request.log);
return this.success(logs as unknown as ActivityLogItemDto[]);
}
// ==========================================================================
// QUEUE AND WORKER MONITORING
// ==========================================================================
/**
* Get worker statuses.
*
* Returns the running status of all BullMQ workers.
*
* @summary Get worker statuses
* @returns Array of worker statuses
*/
@Get('workers/status')
@SuccessResponse(200, 'Worker statuses')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getWorkerStatuses(
@Request() _request: ExpressRequest,
): Promise<SuccessResponseType<WorkerStatusDto[]>> {
const workerStatuses = await monitoringService.getWorkerStatuses();
return this.success(workerStatuses);
}
/**
* Get queue statuses.
*
* Returns job counts for all BullMQ queues.
*
* @summary Get queue statuses
* @returns Array of queue statuses
*/
@Get('queues/status')
@SuccessResponse(200, 'Queue statuses')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getQueueStatuses(
@Request() _request: ExpressRequest,
): Promise<SuccessResponseType<QueueStatusDto[]>> {
const queueStatuses = await monitoringService.getQueueStatuses();
return this.success(queueStatuses);
}
/**
* Retry a failed job.
*
* Marks a failed job in a queue for retry.
*
* @summary Retry failed job
* @param queueName Queue name
* @param jobId Job ID
* @param request Express request for logging
* @returns Success message
*/
@Post('jobs/{queueName}/{jobId}/retry')
@Middlewares(adminTriggerLimiter)
@SuccessResponse(200, 'Job marked for retry')
@Response<ErrorResponse>(400, 'Job is not in failed state')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(404, 'Queue or job not found')
@Response<ErrorResponse>(429, 'Too many requests')
public async retryJob(
@Path()
queueName:
| 'flyer-processing'
| 'email-sending'
| 'analytics-reporting'
| 'file-cleanup'
| 'weekly-analytics-reporting'
| 'token-cleanup',
@Path() jobId: string,
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<MessageResponse>> {
const userProfile = request.user as UserProfile;
await monitoringService.retryFailedJob(queueName, jobId, userProfile.user.user_id);
return this.success({ message: `Job ${jobId} has been successfully marked for retry.` });
}
// ==========================================================================
// BACKGROUND JOB TRIGGERS
// ==========================================================================
/**
* Trigger daily deal check.
*
* Manually triggers the daily deal check background job.
* This is a fire-and-forget operation.
*
* @summary Trigger daily deal check
* @param request Express request for logging
* @returns Job triggered message
*/
@Post('trigger/daily-deal-check')
@Middlewares(adminTriggerLimiter)
@SuccessResponse(202, 'Job triggered')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(429, 'Too many requests')
public async triggerDailyDealCheck(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<JobTriggeredResponse>> {
const userProfile = request.user as UserProfile;
request.log.info(
`[Admin] Manual trigger for daily deal check received from user: ${userProfile.user.user_id}`,
);
// Fire-and-forget operation
backgroundJobService.runDailyDealCheck();
this.setStatus(202);
return this.success({
message:
'Daily deal check job has been triggered successfully. It will run in the background.',
});
}
/**
* Trigger analytics report.
*
* Enqueues a job to generate the daily analytics report.
*
* @summary Trigger analytics report
* @param request Express request for logging
* @returns Job enqueued message with job ID
*/
@Post('trigger/analytics-report')
@Middlewares(adminTriggerLimiter)
@SuccessResponse(202, 'Job enqueued')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(429, 'Too many requests')
public async triggerAnalyticsReport(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<JobTriggeredResponse>> {
const userProfile = request.user as UserProfile;
request.log.info(
`[Admin] Manual trigger for analytics report generation received from user: ${userProfile.user.user_id}`,
);
const jobId = await backgroundJobService.triggerAnalyticsReport();
this.setStatus(202);
return this.success({
message: `Analytics report generation job has been enqueued successfully. Job ID: ${jobId}`,
jobId,
});
}
/**
* Trigger weekly analytics.
*
* Enqueues a job to generate the weekly analytics report.
*
* @summary Trigger weekly analytics
* @param request Express request for logging
* @returns Job enqueued message with job ID
*/
@Post('trigger/weekly-analytics')
@Middlewares(adminTriggerLimiter)
@SuccessResponse(202, 'Job enqueued')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(429, 'Too many requests')
public async triggerWeeklyAnalytics(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<JobTriggeredResponse>> {
const userProfile = request.user as UserProfile;
request.log.info(
`[Admin] Manual trigger for weekly analytics report received from user: ${userProfile.user.user_id}`,
);
const jobId = await backgroundJobService.triggerWeeklyAnalyticsReport();
this.setStatus(202);
return this.success({ message: 'Successfully enqueued weekly analytics job.', jobId });
}
/**
* Trigger token cleanup.
*
* Enqueues a job to clean up expired password reset tokens.
*
* @summary Trigger token cleanup
* @param request Express request for logging
* @returns Job enqueued message with job ID
*/
@Post('trigger/token-cleanup')
@Middlewares(adminTriggerLimiter)
@SuccessResponse(202, 'Job enqueued')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(429, 'Too many requests')
public async triggerTokenCleanup(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<JobTriggeredResponse>> {
const userProfile = request.user as UserProfile;
request.log.info(
`[Admin] Manual trigger for token cleanup received from user: ${userProfile.user.user_id}`,
);
const jobId = await backgroundJobService.triggerTokenCleanup();
this.setStatus(202);
return this.success({ message: 'Successfully enqueued token cleanup job.', jobId });
}
/**
* Trigger failing test job.
*
* Enqueues a test job designed to fail for testing retry mechanisms.
* Used for development and testing purposes only.
*
* @summary Trigger failing test job
* @param request Express request for logging
* @returns Job enqueued message with job ID
*/
@Post('trigger/failing-job')
@Middlewares(adminTriggerLimiter)
@SuccessResponse(202, 'Job enqueued')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(429, 'Too many requests')
public async triggerFailingJob(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<JobTriggeredResponse>> {
const userProfile = request.user as UserProfile;
request.log.info(
`[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`,
);
// Add a job with a special flag that the worker will recognize as a deliberate failure
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
this.setStatus(202);
return this.success({
message: `Failing test job has been enqueued successfully. Job ID: ${job.id}`,
jobId: job.id,
});
}
// ==========================================================================
// SYSTEM OPERATIONS
// ==========================================================================
/**
* Clear geocode cache.
*
* Clears all geocoded address data from Redis cache.
*
* @summary Clear geocode cache
* @param request Express request for logging
* @returns Cache clear result
*/
@Post('system/clear-geocode-cache')
@Middlewares(adminTriggerLimiter)
@SuccessResponse(200, 'Cache cleared')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(429, 'Too many requests')
public async clearGeocodeCache(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<CacheClearResponse>> {
const userProfile = request.user as UserProfile;
request.log.info(
`[Admin] Manual trigger for geocode cache clear received from user: ${userProfile.user.user_id}`,
);
const keysDeleted = await geocodingService.clearGeocodeCache(request.log);
return this.success({
message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.`,
});
}
/**
* Clear application cache.
*
* Clears cached flyers, brands, and stats data from Redis.
*
* @summary Clear application cache
* @param request Express request for logging
* @returns Cache clear result with details
*/
@Post('system/clear-cache')
@Middlewares(adminTriggerLimiter)
@SuccessResponse(200, 'Cache cleared')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
@Response<ErrorResponse>(429, 'Too many requests')
public async clearApplicationCache(
@Request() request: ExpressRequest,
): Promise<SuccessResponseType<CacheClearResponse>> {
const userProfile = request.user as UserProfile;
request.log.info(`[Admin] Manual cache clear received from user: ${userProfile.user.user_id}`);
const [flyersDeleted, brandsDeleted, statsDeleted] = await Promise.all([
cacheService.invalidateFlyers(request.log),
cacheService.invalidateBrands(request.log),
cacheService.invalidateStats(request.log),
]);
const totalDeleted = flyersDeleted + brandsDeleted + statsDeleted;
return this.success({
message: `Successfully cleared the application cache. ${totalDeleted} keys were removed.`,
details: {
flyers: flyersDeleted,
brands: brandsDeleted,
stats: statsDeleted,
},
});
}
// ==========================================================================
// FEATURE FLAGS
// ==========================================================================
/**
* Get feature flags.
*
* Returns the current state of all feature flags.
* See ADR-024 for feature flag documentation.
*
* @summary Get feature flags
* @returns Feature flags and their states
*/
@Get('feature-flags')
@SuccessResponse(200, 'Feature flags')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getFeatureFlags(
@Request() _request: ExpressRequest,
): Promise<SuccessResponseType<FeatureFlagsResponse>> {
const flags = getFeatureFlags();
return this.success({ flags });
}
// ==========================================================================
// WEBSOCKET STATISTICS
// ==========================================================================
/**
* Get WebSocket statistics.
*
* Returns real-time WebSocket connection statistics.
* See ADR-022 for WebSocket implementation details.
*
* @summary Get WebSocket stats
* @param request Express request for logging
* @returns Connection statistics
*/
@Get('websocket/stats')
@SuccessResponse(200, 'WebSocket statistics')
@Response<ErrorResponse>(401, 'Unauthorized')
@Response<ErrorResponse>(403, 'Forbidden - Admin role required')
public async getWebSocketStats(
@Request() _request: ExpressRequest,
): Promise<SuccessResponseType<WebSocketStatsDto>> {
const { websocketService } = await import('../services/websocketService.server');
const stats = websocketService.getConnectionStats();
return this.success(stats);
}
}