// src/routes/gamification.routes.ts import express, { NextFunction } from 'express'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { z } from 'zod'; import passport, { isAdmin } from '../config/passport'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { gamificationService } from '../services/gamificationService'; // Removed: import { logger } from '../services/logger.server'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { UserProfile } from '../types'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { validateRequest } from '../middleware/validation.middleware'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { requiredString, optionalNumeric } from '../utils/zodUtils'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { publicReadLimiter, userReadLimiter, adminTriggerLimiter } from '../config/rateLimiters'; import { sendSuccess } from '../utils/apiResponse'; const router = express.Router(); const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes. // --- Zod Schemas for Gamification Routes (as per ADR-003) --- const leaderboardQuerySchema = z.object({ limit: optionalNumeric({ default: 10, integer: true, positive: true, max: 50 }), }); const leaderboardSchema = z.object({ query: leaderboardQuerySchema, }); const awardAchievementSchema = z.object({ body: z.object({ userId: requiredString('userId is required.'), achievementName: requiredString('achievementName is required.'), }), }); // --- Public Routes --- /** * @openapi * /achievements: * get: * summary: Get all achievements * description: Returns the master list of all available achievements in the system. This is a public endpoint. * tags: * - Achievements * responses: * 200: * description: List of all achievements * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: array * items: * $ref: '#/components/schemas/Achievement' */ router.get('/', publicReadLimiter, async (req, res, next: NextFunction) => { try { const achievements = await gamificationService.getAllAchievements(req.log); sendSuccess(res, achievements); } catch (error) { req.log.error({ error }, 'Error fetching all achievements in /api/achievements:'); next(error); } }); /** * @openapi * /achievements/leaderboard: * get: * summary: Get leaderboard * description: Returns the top users ranked by total points earned from achievements. This is a public endpoint. * tags: * - Achievements * parameters: * - in: query * name: limit * schema: * type: integer * minimum: 1 * maximum: 50 * default: 10 * description: Maximum number of users to return * responses: * 200: * description: Leaderboard entries * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: array * items: * $ref: '#/components/schemas/LeaderboardUser' */ router.get( '/leaderboard', publicReadLimiter, validateRequest(leaderboardSchema), async (req, res, next: NextFunction): Promise => { try { // The `validateRequest` middleware ensures `req.query` is valid. // We parse it here to apply Zod's coercions (string to number) and defaults. const { limit } = leaderboardQuerySchema.parse(req.query); const leaderboard = await gamificationService.getLeaderboard(limit!, req.log); sendSuccess(res, leaderboard); } catch (error) { req.log.error({ error }, 'Error fetching leaderboard:'); next(error); } }, ); // --- Authenticated User Routes --- /** * @openapi * /achievements/me: * get: * summary: Get my achievements * description: Returns all achievements earned by the authenticated user. * tags: * - Achievements * security: * - bearerAuth: [] * responses: * 200: * description: List of user's earned achievements * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: array * items: * $ref: '#/components/schemas/UserAchievement' * 401: * description: Unauthorized - JWT token missing or invalid * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' */ router.get( '/me', passport.authenticate('jwt', { session: false }), userReadLimiter, async (req, res, next: NextFunction): Promise => { const userProfile = req.user as UserProfile; try { const userAchievements = await gamificationService.getUserAchievements( userProfile.user.user_id, req.log, ); sendSuccess(res, userAchievements); } catch (error) { req.log.error( { error, userId: userProfile.user.user_id }, 'Error fetching user achievements:', ); next(error); } }, ); // --- Admin-Only Routes --- // Apply authentication and admin-check middleware to the entire admin sub-router. adminGamificationRouter.use(passport.authenticate('jwt', { session: false }), isAdmin); /** * @openapi * /achievements/award: * post: * summary: Award achievement to user (Admin only) * description: Manually award an achievement to a specific user. Requires admin role. * tags: * - Achievements * - Admin * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - userId * - achievementName * properties: * userId: * type: string * format: uuid * description: The user ID to award the achievement to * achievementName: * type: string * description: The name of the achievement to award * example: First-Upload * responses: * 200: * description: Achievement awarded successfully * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * example: true * data: * type: object * properties: * message: * type: string * example: Successfully awarded 'First-Upload' to user abc123. * 401: * description: Unauthorized - JWT token missing or invalid * 403: * description: Forbidden - User is not an admin */ adminGamificationRouter.post( '/award', adminTriggerLimiter, validateRequest(awardAchievementSchema), async (req, res, next: NextFunction): Promise => { // Infer type and cast request object as per ADR-003 type AwardAchievementRequest = z.infer; const { body } = req as unknown as AwardAchievementRequest; try { await gamificationService.awardAchievement(body.userId, body.achievementName, req.log); sendSuccess(res, { message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`, }); } catch (error) { next(error); } }, ); // Mount the admin sub-router onto the main gamification router. router.use(adminGamificationRouter); export default router;