From 8604be4720a2766e51f065871c2994902dc0c572 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Thu, 4 Dec 2025 13:13:34 -0800 Subject: [PATCH] refactor: enhance type safety by adding NextFunction type to async route handlers --- src/routes/admin.ts | 17 +++++++++-------- src/routes/ai.test.ts | 1 - src/routes/ai.ts | 20 ++++++++++---------- src/routes/budget.ts | 12 ++++++------ src/routes/gamification.ts | 10 +++++----- src/routes/public.ts | 12 ++++++------ src/types/express.d.ts | 1 + 7 files changed, 37 insertions(+), 36 deletions(-) diff --git a/src/routes/admin.ts b/src/routes/admin.ts index b8ca460d..debf99b0 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,5 +1,5 @@ // src/routes/admin.ts -import { Router } from 'express'; +import { Router, NextFunction } from 'express'; import passport from './passport'; import { isAdmin } from './passport'; // Correctly imported import multer from 'multer'; @@ -7,6 +7,7 @@ import multer from 'multer'; import * as db from '../services/db'; import { logger } from '../services/logger.server'; import { UserProfile } from '../types'; +import { AsyncRequestHandler } from '../types/express'; import { clearGeocodeCache } from '../services/geocodingService.server'; // --- Bull Board (Job Queue UI) Imports --- @@ -92,7 +93,7 @@ router.post('/corrections/:id/reject', async (req, res) => { res.status(200).json({ message: 'Correction rejected successfully.' }); }); -router.put('/corrections/:id', async (req, res, next) => { +router.put('/corrections/:id', async (req, res, next: NextFunction) => { const correctionId = parseInt(req.params.id, 10); const { suggested_value } = req.body; if (!suggested_value) { @@ -170,7 +171,7 @@ router.get('/users/:id', async (req, res) => { res.json(user); }); -router.put('/users/:id', async (req, res, next) => { +router.put('/users/:id', async (req, res, next: NextFunction) => { const { role } = req.body; if (!role || !['user', 'admin'].includes(role)) { return res.status(400).json({ message: 'A valid role ("user" or "admin") is required.' }); @@ -202,7 +203,7 @@ router.delete('/users/:id', async (req, res) => { * POST /api/admin/trigger/daily-deal-check - Manually trigger the daily deal check job. * This is useful for testing or forcing an update without waiting for the cron schedule. */ -router.post('/trigger/daily-deal-check', async (req, res, next) => { +router.post('/trigger/daily-deal-check', async (req, res, next: NextFunction) => { const adminUser = req.user as UserProfile; logger.info(`[Admin] Manual trigger for daily deal check received from user: ${adminUser.user_id}`); @@ -221,7 +222,7 @@ router.post('/trigger/daily-deal-check', async (req, res, next) => { * POST /api/admin/trigger/analytics-report - Manually enqueue a job to generate the daily analytics report. * This is useful for testing or re-generating a report without waiting for the cron schedule. */ -router.post('/trigger/analytics-report', async (req, res, next) => { +router.post('/trigger/analytics-report', async (req, res, next: NextFunction) => { const adminUser = req.user as UserProfile; logger.info(`[Admin] Manual trigger for analytics report generation received from user: ${adminUser.user_id}`); @@ -243,7 +244,7 @@ router.post('/trigger/analytics-report', async (req, res, next) => { * POST /api/admin/flyers/:flyerId/cleanup - Enqueue a job to clean up a flyer's files. * This is triggered by an admin after they have verified the flyer processing was successful. */ -router.post('/flyers/:flyerId/cleanup', async (req, res, next) => { +router.post('/flyers/:flyerId/cleanup', async (req, res, next: NextFunction) => { const adminUser = req.user as UserProfile; const flyerId = parseInt(req.params.flyerId, 10); @@ -262,7 +263,7 @@ router.post('/flyers/:flyerId/cleanup', async (req, res, next) => { * POST /api/admin/trigger/failing-job - Enqueue a test job designed to fail. * This is for testing the retry mechanism and Bull Board UI. */ -router.post('/trigger/failing-job', async (req, res, next) => { +router.post('/trigger/failing-job', async (req, res, next: NextFunction) => { const adminUser = req.user as UserProfile; logger.info(`[Admin] Manual trigger for a failing job received from user: ${adminUser.user_id}`); @@ -279,7 +280,7 @@ router.post('/trigger/failing-job', async (req, res, next) => { * POST /api/admin/system/clear-geocode-cache - Clears the Redis cache for geocoded addresses. * Requires admin privileges. */ -router.post('/system/clear-geocode-cache', async (req, res, next) => { +router.post('/system/clear-geocode-cache', async (req, res, next: NextFunction) => { const adminUser = req.user as UserProfile; logger.info(`[Admin] Manual trigger for geocode cache clear received from user: ${adminUser.user_id}`); diff --git a/src/routes/ai.test.ts b/src/routes/ai.test.ts index b2a219f9..747c9258 100644 --- a/src/routes/ai.test.ts +++ b/src/routes/ai.test.ts @@ -10,7 +10,6 @@ import * as db from '../services/db'; // Mock the AI service to avoid making real AI calls vi.mock('../services/aiService.server'); -const mockedAiService = aiService as Mocked; // Mock the entire db service, as the /flyers/process route uses it. vi.mock('../services/db'); // Keep this mock, as db is used by the route diff --git a/src/routes/ai.ts b/src/routes/ai.ts index 4f2207bd..65c4c5dd 100644 --- a/src/routes/ai.ts +++ b/src/routes/ai.ts @@ -72,7 +72,7 @@ router.use((req: Request, res: Response, next: NextFunction) => { * NEW ENDPOINT: Accepts a single flyer file (PDF or image), enqueues it for * background processing, and immediately returns a job ID. */ -router.post('/upload-and-process', optionalAuth, uploadToDisk.single('flyerFile'), async (req, res, next) => { +router.post('/upload-and-process', optionalAuth, uploadToDisk.single('flyerFile'), async (req, res, next: NextFunction) => { try { if (!req.file) { return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' }); @@ -157,7 +157,7 @@ router.get('/jobs/:jobId/status', async (req, res) => { * in the flyer upload workflow after the AI has extracted the data. * It uses `optionalAuth` to handle submissions from both anonymous and authenticated users. */ -router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'), async (req, res, next) => { +router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'), async (req, res, next: NextFunction) => { try { if (!req.file) { return res.status(400).json({ message: 'Flyer image file is required.' }); @@ -187,7 +187,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'), // No explicit `data` field found. Attempt to interpret req.body as an object (Express may have parsed multipart fields differently). try { parsed = typeof req.body === 'string' ? JSON.parse(req.body) : req.body; - } catch (err) { // eslint-disable-line + } catch (err) { logger.warn('[API /ai/flyers/process] Failed to JSON.parse req.body; using empty object', { error: errMsg(err) }); parsed = req.body || {}; } @@ -293,7 +293,7 @@ router.post('/flyers/process', optionalAuth, uploadToDisk.single('flyerImage'), * This endpoint checks if an image is a flyer. It uses `optionalAuth` to allow * both authenticated and anonymous users to perform this check. */ -router.post('/check-flyer', optionalAuth, uploadToDisk.single('image'), async (req, res, next) => { +router.post('/check-flyer', optionalAuth, uploadToDisk.single('image'), async (req, res, next: NextFunction) => { try { if (!req.file) { return res.status(400).json({ message: 'Image file is required.' }); @@ -305,7 +305,7 @@ router.post('/check-flyer', optionalAuth, uploadToDisk.single('image'), async (r } }); -router.post('/extract-address', optionalAuth, uploadToDisk.single('image'), async (req, res, next) => { +router.post('/extract-address', optionalAuth, uploadToDisk.single('image'), async (req, res, next: NextFunction) => { try { if (!req.file) { return res.status(400).json({ message: 'Image file is required.' }); @@ -317,7 +317,7 @@ router.post('/extract-address', optionalAuth, uploadToDisk.single('image'), asyn } }); -router.post('/extract-logo', optionalAuth, uploadToDisk.array('images'), async (req, res, next) => { +router.post('/extract-logo', optionalAuth, uploadToDisk.array('images'), async (req, res, next: NextFunction) => { try { if (!req.files || !Array.isArray(req.files) || req.files.length === 0) { return res.status(400).json({ message: 'Image files are required.' }); @@ -329,7 +329,7 @@ router.post('/extract-logo', optionalAuth, uploadToDisk.array('images'), async ( } }); -router.post('/quick-insights', passport.authenticate('jwt', { session: false }), async (req, res, next) => { +router.post('/quick-insights', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => { try { logger.info(`Server-side quick insights requested.`); res.status(200).json({ text: "This is a server-generated quick insight: buy the cheap stuff!" }); // Stubbed response @@ -338,7 +338,7 @@ router.post('/quick-insights', passport.authenticate('jwt', { session: false }), } }); -router.post('/deep-dive', passport.authenticate('jwt', { session: false }), async (req, res, next) => { +router.post('/deep-dive', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => { try { logger.info(`Server-side deep dive requested.`); res.status(200).json({ text: "This is a server-generated deep dive analysis. It is very detailed." }); // Stubbed response @@ -347,7 +347,7 @@ router.post('/deep-dive', passport.authenticate('jwt', { session: false }), asyn } }); -router.post('/search-web', passport.authenticate('jwt', { session: false }), async (req, res, next) => { +router.post('/search-web', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => { try { logger.info(`Server-side web search requested.`); res.status(200).json({ text: "The web says this is good.", sources: [] }); // Stubbed response @@ -356,7 +356,7 @@ router.post('/search-web', passport.authenticate('jwt', { session: false }), asy } }); -router.post('/plan-trip', passport.authenticate('jwt', { session: false }), async (req, res, next) => { +router.post('/plan-trip', passport.authenticate('jwt', { session: false }), async (req, res, next: NextFunction) => { // try { // const { items, store, userLocation } = req.body; // logger.info(`Server-side trip planning requested for user.`); diff --git a/src/routes/budget.ts b/src/routes/budget.ts index 6145ae2e..3c912165 100644 --- a/src/routes/budget.ts +++ b/src/routes/budget.ts @@ -1,5 +1,5 @@ // src/routes/budget.ts -import express from 'express'; +import express, { NextFunction } from 'express'; import passport from './passport'; import { getBudgetsForUser, @@ -19,7 +19,7 @@ router.use(passport.authenticate('jwt', { session: false })); /** * GET /api/budgets - Get all budgets for the authenticated user. */ -router.get('/', async (req, res, next) => { +router.get('/', async (req, res, next: NextFunction) => { const user = req.user as UserProfile; try { const budgets = await getBudgetsForUser(user.user_id); @@ -33,7 +33,7 @@ router.get('/', async (req, res, next) => { /** * POST /api/budgets - Create a new budget for the authenticated user. */ -router.post('/', async (req, res, next) => { +router.post('/', async (req, res, next: NextFunction) => { const user = req.user as UserProfile; try { const newBudget = await createBudget(user.user_id, req.body); @@ -47,7 +47,7 @@ router.post('/', async (req, res, next) => { /** * PUT /api/budgets/:id - Update an existing budget. */ -router.put('/:id', async (req, res, next) => { +router.put('/:id', async (req, res, next: NextFunction) => { const user = req.user as UserProfile; const budgetId = parseInt(req.params.id, 10); try { @@ -62,7 +62,7 @@ router.put('/:id', async (req, res, next) => { /** * DELETE /api/budgets/:id - Delete a budget. */ -router.delete('/:id', async (req, res, next) => { +router.delete('/:id', async (req, res, next: NextFunction) => { const user = req.user as UserProfile; const budgetId = parseInt(req.params.id, 10); try { @@ -78,7 +78,7 @@ router.delete('/:id', async (req, res, next) => { * GET /api/spending-analysis - Get spending breakdown by category for a date range. * Query params: startDate (YYYY-MM-DD), endDate (YYYY-MM-DD) */ -router.get('/spending-analysis', async (req, res, next) => { +router.get('/spending-analysis', async (req, res, next: NextFunction) => { const user = req.user as UserProfile; const { startDate, endDate } = req.query; diff --git a/src/routes/gamification.ts b/src/routes/gamification.ts index b3ffc5a2..52350543 100644 --- a/src/routes/gamification.ts +++ b/src/routes/gamification.ts @@ -1,5 +1,5 @@ // src/routes/gamification.ts -import express from 'express'; +import express, { NextFunction } from 'express'; import passport, { isAdmin } from './passport'; import { getAllAchievements, getUserAchievements, awardAchievement, getLeaderboard } from '../services/db'; import { logger } from '../services/logger.server'; @@ -11,7 +11,7 @@ const router = express.Router(); * GET /api/achievements - Get the master list of all available achievements. * This is a public endpoint. */ -router.get('/', async (req, res, next) => { +router.get('/', async (req, res, next: NextFunction) => { try { const achievements = await getAllAchievements(); res.json(achievements); @@ -25,7 +25,7 @@ router.get('/', async (req, res, next) => { * GET /api/achievements/leaderboard - Get the top users by points. * This is a public endpoint. */ -router.get('/leaderboard', async (req, res, next) => { +router.get('/leaderboard', async (req, res, next: NextFunction) => { // Allow client to specify a limit, but default to 10 and cap it at 50. const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50); @@ -45,7 +45,7 @@ router.get('/leaderboard', async (req, res, next) => { router.get( '/me', passport.authenticate('jwt', { session: false }), - async (req, res, next) => { + async (req, res, next: NextFunction) => { const user = req.user as UserProfile; try { const userAchievements = await getUserAchievements(user.user_id); @@ -65,7 +65,7 @@ router.post( '/award', passport.authenticate('jwt', { session: false }), isAdmin, - async (req, res, next) => { + async (req, res, next: NextFunction) => { const { userId, achievementName } = req.body; if (!userId || !achievementName) { diff --git a/src/routes/public.ts b/src/routes/public.ts index 2e100d0c..48dc9d2c 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -1,5 +1,5 @@ // src/routes/public.ts -import { Router, Request, Response } from 'express'; +import { Router, Request, Response, NextFunction } from 'express'; import * as db from '../services/db'; import { logger } from '../services/logger.server'; import fs from 'fs/promises'; @@ -67,7 +67,7 @@ router.get('/flyers', async (req, res, next) => { } }); -router.get('/master-items', async (req, res, next) => { +router.get('/master-items', async (req, res, next: NextFunction) => { try { const masterItems = await db.getAllMasterItems(); res.json(masterItems); @@ -77,7 +77,7 @@ router.get('/master-items', async (req, res, next) => { } }); -router.get('/flyers/:id/items', async (req, res, next) => { +router.get('/flyers/:id/items', async (req, res, next: NextFunction) => { try { const flyerId = parseInt(req.params.id, 10); const items = await db.getFlyerItems(flyerId); @@ -88,7 +88,7 @@ router.get('/flyers/:id/items', async (req, res, next) => { } }); -router.post('/flyer-items/batch-fetch', async (req, res, next) => { +router.post('/flyer-items/batch-fetch', async (req, res, next: NextFunction) => { const { flyerIds } = req.body; if (!Array.isArray(flyerIds)) { return res.status(400).json({ message: 'flyerIds must be an array.' }); @@ -101,7 +101,7 @@ router.post('/flyer-items/batch-fetch', async (req, res, next) => { } }); -router.post('/flyer-items/batch-count', async (req, res, next) => { +router.post('/flyer-items/batch-count', async (req, res, next: NextFunction) => { const { flyerIds } = req.body; if (!Array.isArray(flyerIds)) { return res.status(400).json({ message: 'flyerIds must be an array.' }); @@ -114,7 +114,7 @@ router.post('/flyer-items/batch-count', async (req, res, next) => { } }); -router.get('/recipes/by-sale-percentage', async (req, res, next) => { +router.get('/recipes/by-sale-percentage', async (req, res, next: NextFunction) => { const minPercentageStr = req.query.minPercentage as string || '50.0'; const minPercentage = parseFloat(minPercentageStr); diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 66b904b5..7cf1721a 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -1,5 +1,6 @@ // src/types/express.d.ts import { Request, Response, NextFunction, RequestHandler } from 'express'; +import * as qs from 'qs'; /** * Defines a more accurate type for asynchronous Express route handlers.