import express, { Request, Response, NextFunction } from 'express'; import passport from 'passport'; import { Strategy as LocalStrategy } from 'passport-local'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { Strategy as GitHubStrategy } from 'passport-github2'; import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; import bcrypt from 'bcrypt'; import zxcvbn from 'zxcvbn'; import jwt from 'jsonwebtoken'; import rateLimit from 'express-rate-limit'; import dotenv from 'dotenv'; import cookieParser from 'cookie-parser'; import crypto from 'crypto'; import fs from 'fs/promises'; import multer from 'multer'; import * as db from './src/services/db'; import { logger } from './src/services/logger'; // This import is correct import * as aiService from './src/services/aiService.server'; // Import the new server-side AI service import { sendPasswordResetEmail, sendWelcomeEmail } from './src/services/emailService'; import { UserProfile, ShoppingListItem } from './src/types'; // Load environment variables from a .env file at the root of your project dotenv.config(); const app = express(); app.use(express.json()); // Middleware to parse JSON request bodies app.use(cookieParser()); // Middleware to parse cookies app.use(passport.initialize()); // Initialize Passport // --- Logging Middleware --- const getDurationInMilliseconds = (start: [number, number]): number => { const NS_PER_SEC = 1e9; const NS_TO_MS = 1e6; const diff = process.hrtime(start); return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_MS; }; const requestLogger = (req: Request, res: Response, next: NextFunction) => { const start = process.hrtime(); const { method, originalUrl } = req; res.on('finish', () => { const user = req.user as { id?: string } | undefined; const durationInMilliseconds = getDurationInMilliseconds(start); const { statusCode } = res; const userIdentifier = user?.id ? ` (User: ${user.id})` : ''; const logMessage = `${method} ${originalUrl} ${statusCode} ${durationInMilliseconds.toFixed(2)}ms${userIdentifier}`; if (statusCode >= 500) logger.error(logMessage); else if (statusCode >= 400) logger.warn(logMessage); else logger.info(logMessage); }); next(); }; app.use(requestLogger); // Use the logging middleware for all requests // --- Configuration --- // IMPORTANT: Use a strong, randomly generated secret key and store it securely // in your .env file, not hardcoded here. const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_jwt_key_change_this'; if (JWT_SECRET === 'your_super_secret_jwt_key_change_this') { logger.warn('Security Warning: JWT_SECRET is using a default, insecure value. Please set a strong secret in your .env file.'); } // --- Multer Configuration for File Uploads --- const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets'; const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, storagePath); }, filename: function (req, file, cb) { // Create a unique filename to avoid conflicts const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, file.fieldname + '-' + uniqueSuffix + '-' + file.originalname); } }); const upload = multer({ storage: storage }); // --- Rate Limiting Configuration --- // Rate limiter for forgot-password requests to prevent email spam. // This limits a single IP address to 5 requests every 15 minutes. const forgotPasswordLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, message: 'Too many password reset requests from this IP, please try again after 15 minutes.', standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); // Rate limiter for reset-password attempts to prevent token brute-forcing. const resetPasswordLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // A slightly higher limit as it requires a token message: 'Too many password reset attempts from this IP, please try again after 15 minutes.', standardHeaders: true, legacyHeaders: false, }); // --- Passport Local Strategy (for email/password login) --- passport.use(new LocalStrategy( { usernameField: 'email' }, // Tell Passport to expect 'email' instead of 'username' async (email, password, done) => { try { // 1. Find the user in your PostgreSQL database by email. const user = await db.findUserByEmail(email); if (!user) { // User not found logger.warn(`Login attempt failed for non-existent user: ${email}`); return done(null, false, { message: 'Incorrect email or password.' }); } // 2. Compare the submitted password with the hashed password in your DB. const isMatch = await bcrypt.compare(password, user.password_hash); if (!isMatch) { // Password does not match logger.warn(`Login attempt failed for user ${email} due to incorrect password.`); return done(null, false, { message: 'Incorrect email or password.' }); } // 3. Success! Return the user object (without password_hash for security). const { password_hash: _password_hash, ...userWithoutHash } = user; logger.info(`User successfully authenticated: ${email}`); return done(null, userWithoutHash); } catch (err) { logger.error('Error during local authentication strategy:', { error: err }); return done(err); } } )); // --- Passport Google OAuth 2.0 Strategy --- passport.use(new GoogleStrategy({ clientID: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, callbackURL: '/api/auth/google/callback', // Must match the one in Google Cloud Console scope: ['profile', 'email'] }, async (accessToken, refreshToken, profile, done) => { try { const email = profile.emails?.[0]?.value; if (!email) { return done(new Error("No email found in Google profile."), false); } // Check if user already exists in our database let user = await db.findUserByEmail(email); if (user) { // User exists, proceed to log them in. logger.info(`Google OAuth successful for existing user: ${email}`); const { password_hash, ...userWithoutHash } = user; return done(null, userWithoutHash); } else { // User does not exist, create a new account for them. logger.info(`Google OAuth: creating new user for email: ${email}`); // Since this is an OAuth user, they don't have a password. // We pass `null` for the password hash. const newUser = await db.createUser(email, null, { full_name: profile.displayName, avatar_url: profile.photos?.[0]?.value }); // Send a welcome email to the new user try { await sendWelcomeEmail(email, profile.displayName); } catch (emailError) { logger.error(`Failed to send welcome email to new Google user ${email}`, { error: emailError }); // Don't block the login flow if email fails. } // The `createUser` function returns the user object without the password hash. return done(null, newUser); } } catch (err) { logger.error('Error during Google authentication strategy:', { error: err }); return done(err, false); } } )); // --- Passport GitHub OAuth 2.0 Strategy --- passport.use(new GitHubStrategy({ clientID: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, callbackURL: '/api/auth/github/callback', // Must match the one in GitHub OAuth App settings scope: ['user:email'] // Request email access }, async (accessToken, refreshToken, profile, done) => { try { const email = profile.emails?.[0]?.value; if (!email) { return done(new Error("No public email found in GitHub profile. Please ensure your primary email is public or add one."), false); } // Check if user already exists in our database let user = await db.findUserByEmail(email); if (user) { // User exists, proceed to log them in. logger.info(`GitHub OAuth successful for existing user: ${email}`); const { password_hash, ...userWithoutHash } = user; return done(null, userWithoutHash); } else { // User does not exist, create a new account for them. logger.info(`GitHub OAuth: creating new user for email: ${email}`); // Since this is an OAuth user, they don't have a password. // We pass `null` for the password hash. const newUser = await db.createUser(email, null, { full_name: profile.displayName || profile.username, // GitHub profile might not have displayName avatar_url: profile.photos?.[0]?.value }); // Send a welcome email to the new user try { await sendWelcomeEmail(email, profile.displayName || profile.username); } catch (emailError) { logger.error(`Failed to send welcome email to new GitHub user ${email}`, { error: emailError }); // Don't block the login flow if email fails. } // The `createUser` function returns the user object without the password hash. return done(null, newUser); } } catch (err) { logger.error('Error during GitHub authentication strategy:', { error: err }); return done(err, false); } } )); // Serialize and deserialize user are not needed for JWT-based sessions // passport.serializeUser((user, done) => done(null, user)); // passport.deserializeUser((user, done) => done(null, user as any)); // --- Passport JWT Strategy (for protecting API routes) --- const jwtOptions = { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Expect JWT in 'Authorization: Bearer ' header secretOrKey: JWT_SECRET, }; passport.use(new JwtStrategy(jwtOptions, async (jwt_payload, done) => { try { // The jwt_payload contains the data you put into the token during login (e.g., { id: user.id, email: user.email }). // We re-fetch the user from the database here to ensure they are still active and valid. const userProfile = await db.findUserProfileById(jwt_payload.id); if (userProfile) { return done(null, userProfile); // User profile object will be available as req.user in protected routes } else { logger.warn(`JWT authentication failed: user with ID ${jwt_payload.id} not found.`); return done(null, false); // User not found or invalid token } } catch (err) { logger.error('Error during JWT authentication strategy:', { error: err }); return done(err, false); } })); // --- Middleware for Admin Role Check --- const isAdmin = (req: Request, res: Response, next: NextFunction) => { // This middleware should run *after* passport.authenticate('jwt', ...) const userProfile = req.user as UserProfile; // The user object from JWT strategy includes the full UserProfile // We need to fetch the profile to check the role. // The JWT payload only has id and email. Let's adjust the JWT strategy to include the role, // or fetch it here. For security, fetching it is better as roles can change. // Let's assume the profile is attached to req.user. The JWT strategy fetches the user, but not the profile. // Let's modify the JWT strategy to fetch the full user profile. if (userProfile && userProfile.role === 'admin') { next(); } else { logger.warn(`Admin access denied for user: ${userProfile?.id} (${userProfile?.user?.email})`); res.status(403).json({ message: 'Forbidden: Administrator access required.' }); } }; /** * A flexible authentication middleware. It attempts to authenticate via JWT but does NOT * reject the request if authentication fails. This allows routes to handle both * authenticated and anonymous users. If a valid token is present, `req.user` will be * populated; otherwise, it will be undefined, and the request proceeds. */ const optionalAuth = (req: Request, res: Response, next: NextFunction) => { passport.authenticate('jwt', { session: false }, (err, user, info) => { if (user) req.user = user; // Attach user if authentication succeeds next(); // Always proceed to the next middleware })(req, res, next); }; // --- API Routes --- // --- Health & System Check Routes --- app.get('/api/health/ping', (req: Request, res: Response) => { res.status(200).send('pong'); }); app.get('/api/health/db-schema', async (req: Request, res: Response) => { try { const requiredTables = ['users', 'profiles', 'flyers', 'flyer_items', 'stores']; const missingTables = await db.checkTablesExist(requiredTables); if (missingTables.length > 0) { return res.status(500).json({ success: false, message: `Database schema check failed. Missing tables: ${missingTables.join(', ')}.` }); } return res.status(200).json({ success: true, message: 'All required database tables exist.' }); } catch (error) { logger.error('Error during DB schema check:', { error }); return res.status(500).json({ success: false, message: 'An error occurred while checking the database schema.' }); } }); app.get('/api/health/storage', async (req: Request, res: Response) => { // This path should be an absolute path on your server, configured via an environment variable. const storagePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/assets'; try { await fs.access(storagePath, fs.constants.W_OK); // Check for write access return res.status(200).json({ success: true, message: `Storage directory '${storagePath}' is accessible and writable.` }); } catch (error) { logger.error(`Storage check failed for path: ${storagePath}`, { error }); return res.status(500).json({ success: false, message: `Storage check failed. Ensure the directory '${storagePath}' exists and is writable by the application.` }); } }); app.get('/api/health/db-pool', (req: Request, res: Response) => { try { const status = db.getPoolStatus(); // A healthy pool should ideally have 0 waiting clients. A small number might be acceptable under load. const isHealthy = status.waitingCount < 5; // Arbitrary threshold for "healthy" const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`; if (isHealthy) { return res.status(200).json({ success: true, message }); } else { logger.warn(`Database pool health check shows high waiting count: ${status.waitingCount}`); return res.status(500).json({ success: false, message: `Pool may be under stress. ${message}` }); } } catch (error) { logger.error('Error during DB pool health check:', { error }); return res.status(500).json({ success: false, message: 'An error occurred while checking the database pool status.' }); } }); // --- Public Data Routes --- app.get('/api/flyers', async (req: Request, res: Response, next: NextFunction) => { try { const flyers = await db.getFlyers(); res.json(flyers); } catch (error) { logger.error('Error fetching flyers in /api/flyers:', { error }); next(error); } }); app.get('/api/master-items', async (req: Request, res: Response, next: NextFunction) => { try { const masterItems = await db.getAllMasterItems(); res.json(masterItems); } catch (error) { logger.error('Error fetching master items in /api/master-items:', { error }); next(error); } }); app.get('/api/flyers/:id/items', async (req: Request, res: Response, next: NextFunction) => { try { const flyerId = parseInt(req.params.id, 10); const items = await db.getFlyerItems(flyerId); res.json(items); } catch (error) { logger.error('Error fetching flyer items in /api/flyers/:id/items:', { error }); next(error); } }); app.post('/api/flyer-items/batch-fetch', async (req: Request, res: Response, next: NextFunction) => { const { flyerIds } = req.body; if (!Array.isArray(flyerIds)) { return res.status(400).json({ message: 'flyerIds must be an array.' }); } try { const items = await db.getFlyerItemsForFlyers(flyerIds); res.json(items); } catch (error) { next(error); } }); app.post('/api/flyer-items/batch-count', async (req: Request, res: Response, next: NextFunction) => { const { flyerIds } = req.body; if (!Array.isArray(flyerIds)) { return res.status(400).json({ message: 'flyerIds must be an array.' }); } try { const count = await db.countFlyerItemsForFlyers(flyerIds); res.json({ count }); } catch (error) { next(error); } }); app.get('/api/recipes/by-sale-percentage', async (req: Request, res: Response, next: NextFunction) => { const minPercentageStr = req.query.minPercentage as string || '50.0'; const minPercentage = parseFloat(minPercentageStr); if (isNaN(minPercentage) || minPercentage < 0 || minPercentage > 100) { return res.status(400).json({ message: 'Query parameter "minPercentage" must be a number between 0 and 100.' }); } try { const recipes = await db.getRecipesBySalePercentage(minPercentage); res.json(recipes); } catch (error) { next(error); } }); app.get('/api/recipes/by-sale-ingredients', async (req: Request, res: Response, next: NextFunction) => { const minIngredientsStr = req.query.minIngredients as string || '3'; const minIngredients = parseInt(minIngredientsStr, 10); if (isNaN(minIngredients) || minIngredients < 1) { return res.status(400).json({ message: 'Query parameter "minIngredients" must be a positive integer.' }); } try { const recipes = await db.getRecipesByMinSaleIngredients(minIngredients); res.json(recipes); } catch (error) { next(error); } }); app.get('/api/recipes/by-ingredient-and-tag', async (req: Request, res: Response, next: NextFunction) => { const { ingredient, tag } = req.query; if (!ingredient || !tag) { return res.status(400).json({ message: 'Both "ingredient" and "tag" query parameters are required.' }); } try { const recipes = await db.findRecipesByIngredientAndTag(ingredient as string, tag as string); res.json(recipes); } catch (error) { next(error); } }); app.get('/api/stats/most-frequent-sales', async (req: Request, res: Response, next: NextFunction) => { const daysStr = req.query.days as string || '30'; const limitStr = req.query.limit as string || '10'; const days = parseInt(daysStr, 10); const limit = parseInt(limitStr, 10); if (isNaN(days) || days < 1 || days > 365) { return res.status(400).json({ message: 'Query parameter "days" must be an integer between 1 and 365.' }); } if (isNaN(limit) || limit < 1 || limit > 50) { return res.status(400).json({ message: 'Query parameter "limit" must be an integer between 1 and 50.' }); } try { const items = await db.getMostFrequentSaleItems(days, limit); res.json(items); } catch (error) { next(error); } }); // --- Flyer Processing Route --- app.post('/api/flyers/process', upload.single('flyerImage'), async (req: Request, res: Response, next: NextFunction) => { try { if (!req.file) { return res.status(400).json({ message: 'Flyer image file is required.' }); } // The rest of the data is sent as JSON in a 'data' field const { checksum, originalFileName, extractedData } = JSON.parse(req.body.data); // 1. Check for duplicates const existingFlyer = await db.findFlyerByChecksum(checksum); if (existingFlyer) { logger.info(`Processing skipped for duplicate flyer checksum: ${checksum}`); return res.status(200).json({ message: 'Duplicate flyer, processing skipped.', flyer: existingFlyer }); } // 2. Prepare data for database insertion const flyerData = { file_name: originalFileName, image_url: `/assets/${req.file.filename}`, // URL relative to the server's public path checksum: checksum, store_name: extractedData.store_name, valid_from: extractedData.valid_from, valid_to: extractedData.valid_to, store_address: extractedData.store_address, }; // 3. Create flyer and items in a transaction const newFlyer = await db.createFlyerAndItems(flyerData, extractedData.items); logger.info(`Successfully processed and saved new flyer: ${originalFileName}`); res.status(201).json({ message: 'Flyer processed successfully.', flyer: newFlyer }); } catch (error) { logger.error('Error in /api/flyers/process endpoint:', { error }); next(error); } }); // --- Store Logo Upload Route (Protected) --- app.post('/api/stores/:id/logo', passport.authenticate('jwt', { session: false }), upload.single('logoImage'), async (req: Request, res: Response, next: NextFunction) => { const storeId = parseInt(req.params.id, 10); try { if (!req.file) { return res.status(400).json({ message: 'Logo image file is required.' }); } const logoUrl = `/assets/${req.file.filename}`; await db.updateStoreLogo(storeId, logoUrl); res.status(200).json({ message: 'Store logo updated successfully.', logoUrl }); } catch (error) { next(error); } }); // --- Activity Log Route (Protected) --- app.get('/api/activity-log', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const limit = parseInt(req.query.limit as string, 10) || 20; const offset = parseInt(req.query.offset as string, 10) || 0; if (isNaN(limit) || isNaN(offset) || limit <= 0 || offset < 0) { return res.status(400).json({ message: 'Invalid limit or offset parameters.' }); } try { const logs = await db.getActivityLog(limit, offset); res.json(logs); } catch (error) { next(error); } }); // --- Watched Items Routes (Protected) --- app.get('/api/watched-items', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string; email: string }; try { const items = await db.getWatchedItems(user.id); res.json(items); } catch (error) { next(error); } }); app.post('/api/watched-items', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string; email: string }; const { itemName, category } = req.body; try { const newItem = await db.addWatchedItem(user.id, itemName, category); res.status(201).json(newItem); } catch (error) { next(error); } }); app.delete('/api/watched-items/:masterItemId', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string; email: string }; const { masterItemId } = req.params; try { await db.removeWatchedItem(user.id, parseInt(masterItemId, 10)); res.status(204).send(); // No Content } catch (error) { next(error); } }); // --- Authenticated User Routes --- app.get('/api/users/pantry-recipes', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; try { const recipes = await db.findRecipesFromPantry(user.id); res.json(recipes); } catch (error) { next(error); } }); app.get('/api/users/recommended-recipes', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const limitStr = req.query.limit as string || '10'; const limit = parseInt(limitStr, 10); if (isNaN(limit) || limit < 1 || limit > 50) { return res.status(400).json({ message: 'Query parameter "limit" must be an integer between 1 and 50.' }); } try { const recipes = await db.recommendRecipesForUser(user.id, limit); res.json(recipes); } catch (error) { next(error); } }); app.get('/api/users/best-sale-prices', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; try { const deals = await db.getBestSalePricesForUser(user.id); res.json(deals); } catch (error) { next(error); } }); app.get('/api/pantry-items/:id/conversions', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const pantryItemId = parseInt(req.params.id, 10); try { // --- Ownership Check --- const owner = await db.findPantryItemOwner(pantryItemId); if (!owner) { return res.status(404).json({ message: 'Pantry item not found.' }); } if (owner.user_id !== user.id) { logger.warn(`User ${user.id} attempted to access unauthorized pantry item ${pantryItemId}.`); return res.status(403).json({ message: 'Forbidden: You do not have access to this item.' }); } const conversions = await db.suggestPantryItemConversions(pantryItemId); res.json(conversions); } catch (error) { next(error); } }); app.get('/api/menu-plans/:id/shopping-list', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const menuPlanId = parseInt(req.params.id, 10); try { const shoppingListItems = await db.generateShoppingListForMenuPlan(menuPlanId, user.id); res.json(shoppingListItems); } catch (error) { next(error); } }); app.post('/api/shopping-lists/:listId/add-from-menu-plan', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const shoppingListId = parseInt(req.params.listId, 10); const { menuPlanId } = req.body; if (!menuPlanId) { return res.status(400).json({ message: 'menuPlanId is required in the request body.' }); } try { const addedItems = await db.addMenuPlanToShoppingList(menuPlanId, shoppingListId, user.id); res.status(201).json(addedItems); } catch (error) { next(error); } }); app.get('/api/dietary-restrictions', async (req: Request, res: Response, next: NextFunction) => { try { const restrictions = await db.getDietaryRestrictions(); res.json(restrictions); } catch (error) { next(error); } }); app.get('/api/appliances', async (req: Request, res: Response, next: NextFunction) => { try { const appliances = await db.getAppliances(); res.json(appliances); } catch (error) { next(error); } }); // --- Authenticated User Personalization Routes --- app.get('/api/users/me/dietary-restrictions', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; try { const restrictions = await db.getUserDietaryRestrictions(user.id); res.json(restrictions); } catch (error) { next(error); } }); app.put('/api/users/me/dietary-restrictions', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const { restrictionIds } = req.body; if (!Array.isArray(restrictionIds)) { return res.status(400).json({ message: 'restrictionIds must be an array of numbers.' }); } try { await db.setUserDietaryRestrictions(user.id, restrictionIds); res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/users/me/appliances', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; try { const appliances = await db.getUserAppliances(user.id); res.json(appliances); } catch (error) { next(error); } }); app.put('/api/users/me/appliances', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const { applianceIds } = req.body; if (!Array.isArray(applianceIds)) { return res.status(400).json({ message: 'applianceIds must be an array of numbers.' }); } try { await db.setUserAppliances(user.id, applianceIds); res.status(204).send(); } catch (error) { next(error); } }); app.get('/api/users/me/compatible-recipes', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; try { const recipes = await db.getRecipesForUserDiets(user.id); res.json(recipes); } catch (error) { next(error); } }); app.get('/api/users/me/feed', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const limit = parseInt(req.query.limit as string, 10) || 20; const offset = parseInt(req.query.offset as string, 10) || 0; try { const feedItems = await db.getUserFeed(user.id, limit, offset); res.json(feedItems); } catch (error) { next(error); } }); app.post('/api/recipes/:id/fork', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const originalRecipeId = parseInt(req.params.id, 10); try { const forkedRecipe = await db.forkRecipe(user.id, originalRecipeId); res.status(201).json(forkedRecipe); } catch (error) { next(error); } }); app.get('/api/users/favorite-recipes', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; try { const recipes = await db.getUserFavoriteRecipes(user.id); res.json(recipes); } catch (error) { next(error); } }); app.post('/api/users/favorite-recipes', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const { recipeId } = req.body; if (!recipeId) { return res.status(400).json({ message: 'recipeId is required.' }); } try { const favorite = await db.addFavoriteRecipe(user.id, recipeId); res.status(201).json(favorite); } catch (error) { next(error); } }); app.delete('/api/users/favorite-recipes/:recipeId', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const recipeId = parseInt(req.params.recipeId, 10); try { await db.removeFavoriteRecipe(user.id, recipeId); res.status(204).send(); } catch (error) { next(error); } }); // --- Social Routes (Following Users) --- app.post('/api/users/:id/follow', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const follower = req.user as { id: string }; const followingId = req.params.id; try { await db.followUser(follower.id, followingId); res.status(204).send(); } catch (error) { next(error); } }); app.delete('/api/users/:id/follow', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const follower = req.user as { id: string }; const followingId = req.params.id; try { await db.unfollowUser(follower.id, followingId); res.status(204).send(); } catch (error) { next(error); } }); app.post('/api/flyer-items/:id/track', async (req: Request, res: Response, next: NextFunction) => { const itemId = parseInt(req.params.id, 10); const { type } = req.body; // 'view' or 'click' if (type !== 'view' && type !== 'click') { return res.status(400).json({ message: 'Invalid interaction type.' }); } try { // This is a fire-and-forget operation, no need to wait for it. db.trackFlyerItemInteraction(itemId, type); res.status(202).send(); // Accepted } catch (error) { next(error); } }); // --- Price History Route (Protected) --- app.post('/api/price-history', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const { masterItemIds } = req.body; if (!Array.isArray(masterItemIds) || masterItemIds.some(id => typeof id !== 'number')) { return res.status(400).json({ message: 'masterItemIds must be an array of numbers.' }); } try { const historicalData = await db.getHistoricalPriceDataForItems(masterItemIds); res.json(historicalData); } catch (error) { logger.error('Error fetching price history in /api/price-history:', { error }); next(error); } }); // --- Shopping List Routes (Protected) --- app.get('/api/shopping-lists', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string; email: string }; try { const lists = await db.getShoppingLists(user.id); res.json(lists); } catch (error) { next(error); } }); app.post('/api/shopping-lists', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string; email: string }; const { name } = req.body; try { const newList = await db.createShoppingList(user.id, name); res.status(201).json(newList); } catch (error) { next(error); } }); app.delete('/api/shopping-lists/:listId', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string; email: string }; const { listId } = req.params; try { await db.deleteShoppingList(parseInt(listId, 10), user.id); res.status(204).send(); } catch (error) { next(error); } }); app.post('/api/shopping-lists/:listId/items', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const item = req.body; try { const newItem = await db.addShoppingListItem(parseInt(req.params.listId, 10), item); res.status(201).json(newItem); } catch (error) { next(error); } }); app.put('/api/shopping-lists/items/:itemId', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const updates: Partial = req.body; try { const updatedItem = await db.updateShoppingListItem(parseInt(req.params.itemId, 10), updates); res.json(updatedItem); } catch (error) { next(error); } }); app.delete('/api/shopping-lists/items/:itemId', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { try { await db.removeShoppingListItem(parseInt(req.params.itemId, 10)); res.status(204).send(); } catch (error) { next(error); } }); app.post('/api/shopping-lists/:id/complete', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const shoppingListId = parseInt(req.params.id, 10); const { totalSpentCents } = req.body; try { const newTripId = await db.completeShoppingList(shoppingListId, user.id, totalSpentCents); res.status(201).json({ message: 'Shopping list completed and archived.', newTripId }); } catch (error) { next(error); } }); // --- Shopping Trip History Routes --- app.get('/api/shopping-history', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; try { const history = await db.getShoppingTripHistory(user.id); res.json(history); } catch (error) { next(error); } }); // --- Pantry Location Routes --- app.post('/api/pantry/locations', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const { name } = req.body; if (!name) { return res.status(400).json({ message: 'Location name is required.' }); } try { const newLocation = await db.createPantryLocation(user.id, name); res.status(201).json(newLocation); } catch (error) { next(error); } }); // --- Search Logging Route --- app.post('/api/search/log', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const { queryText, resultCount, wasSuccessful } = req.body; try { db.logSearchQuery({ userId: user.id, queryText, resultCount, wasSuccessful }); res.status(202).send(); } catch (error) { next(error); } }); // --- Recipe Comments Routes --- app.get('/api/recipes/:recipeId/comments', async (req: Request, res: Response, next: NextFunction) => { const recipeId = parseInt(req.params.recipeId, 10); try { const comments = await db.getRecipeComments(recipeId); res.json(comments); } catch (error) { next(error); } }); app.post('/api/recipes/:recipeId/comments', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const recipeId = parseInt(req.params.recipeId, 10); const { content, parentCommentId } = req.body; if (!content) { return res.status(400).json({ message: 'Comment content is required.' }); } try { const newComment = await db.addRecipeComment(recipeId, user.id, content, parentCommentId); res.status(201).json(newComment); } catch (error) { next(error); } }); // --- Authentication Routes --- // Registration Route app.post('/api/auth/register', async (req: Request, res: Response, next: NextFunction) => { const { email, password, full_name, avatar_url } = req.body; if (!email || !password) { return res.status(400).json({ message: 'Email and password are required.' }); } // --- Password Strength Check --- const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4) const strength = zxcvbn(password); if (strength.score < MIN_PASSWORD_SCORE) { logger.warn(`Weak password rejected during registration for email: ${email}. Score: ${strength.score}`); // Provide the user with helpful feedback from the strength analysis. const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]); return res.status(400).json({ message: `Password is too weak. ${feedback || 'Please choose a stronger password.'}`.trim() }); } try { const existingUser = await db.findUserByEmail(email); if (existingUser) { logger.warn(`Registration attempt for existing email: ${email}`); return res.status(409).json({ message: 'User with that email already exists.' }); } // Hash the password before storing it const saltRounds = 10; const hashedPassword = await bcrypt.hash(password, saltRounds); logger.info(`Hashing password for new user: ${email}`); // Pass the extra profile data to the createUser function const newUser = await db.createUser(email, hashedPassword, { full_name, avatar_url }); logger.info(`Successfully created new user in DB: ${newUser.email} (ID: ${newUser.id})`); // Immediately log in the user by issuing a JWT const payload = { id: newUser.id, email: newUser.email }; const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); // Token expires in 1 hour // Generate and save a refresh token const refreshToken = crypto.randomBytes(64).toString('hex'); await db.saveRefreshToken(newUser.id, refreshToken); // Send the refresh token in a secure, HttpOnly cookie res.cookie('refreshToken', refreshToken, { httpOnly: true, // The cookie is not accessible via client-side script secure: process.env.NODE_ENV === 'production', // Only send over HTTPS in production maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); return res.status(201).json({ message: 'User registered successfully!', user: payload, token }); } catch (err) { logger.error('Error during /register route handling:', { error: err }); return next(err); // Pass error to Express error handler } }); // Login Route app.post('/api/auth/login', (req: Request, res: Response, next: NextFunction) => { // Use passport.authenticate with the 'local' strategy // { session: false } because we're using JWTs, not server-side sessions. The 'info' object is not used, so it's removed. passport.authenticate('local', { session: false }, (err: Error, user: Express.User | false) => { const { rememberMe } = req.body; // Get the 'rememberMe' flag from the request if (err) { logger.error('Login authentication error in /login route:', { error: err }); return next(err); // Pass server errors to the error handler } if (!user) { // Authentication failed (e.g., incorrect credentials) return res.status(401).json({ message: 'Login failed' }); } // User is authenticated, create and sign a JWT // The user object here is what was returned from the LocalStrategy's `done` callback const typedUser = user as { id: string; email: string }; const payload = { id: typedUser.id, email: typedUser.email }; const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); // Short-lived access token // Generate and save a refresh token const refreshToken = crypto.randomBytes(64).toString('hex'); db.saveRefreshToken(typedUser.id, refreshToken).then(() => { logger.info(`JWT and refresh token issued for user: ${typedUser.email}`); // Set cookie options based on the "Remember Me" flag const cookieOptions = { httpOnly: true, secure: process.env.NODE_ENV === 'production', maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined // 30 days if remembered, otherwise session cookie }; // Send the refresh token in a secure, HttpOnly cookie res.cookie('refreshToken', refreshToken, cookieOptions); return res.json({ user: payload, token: accessToken }); }).catch(tokenErr => { logger.error('Failed to save refresh token during login:', { error: tokenErr }); return next(tokenErr); }); })(req, res, next); // This is crucial for Passport middleware to work correctly }); // Route to request a password reset app.post('/api/auth/forgot-password', forgotPasswordLimiter, async (req: Request, res: Response, next: NextFunction) => { const { email } = req.body; if (!email) { return res.status(400).json({ message: 'Email is required.' }); } try { const user = await db.findUserByEmail(email); if (user) { // Generate a secure, URL-safe token const token = crypto.randomBytes(32).toString('hex'); // Hash the token before storing it in the database for security const saltRounds = 10; const tokenHash = await bcrypt.hash(token, saltRounds); // Set an expiration time (e.g., 1 hour from now) const expiresAt = new Date(Date.now() + 3600000); // 1 hour in milliseconds await db.createPasswordResetToken(user.id, tokenHash, expiresAt); // Construct the reset link const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:5173'}/reset-password/${token}`; try { // Send the password reset email using the new email service. await sendPasswordResetEmail(email, resetLink); } catch (emailError) { // If the email fails to send, log the error but do not expose the failure to the user. // This maintains the security principle of not revealing whether an email exists. logger.error(`Email send failure during password reset for user: ${emailError}`); } } else { logger.warn(`Password reset requested for non-existent email: ${email}`); } // Always return a success message to prevent user enumeration attacks res.status(200).json({ message: 'If an account with that email exists, a password reset link has been sent.' }); } catch (error) { next(error); } }); // Route to reset the password using a token app.post('/api/auth/reset-password', resetPasswordLimiter, async (req: Request, res: Response, next: NextFunction) => { const { token, newPassword } = req.body; if (!token || !newPassword) { return res.status(400).json({ message: 'Token and new password are required.' }); } try { // Find a matching, non-expired token in the database const validTokens = await db.getValidResetTokens(); let tokenRecord; for (const record of validTokens) { const isMatch = await bcrypt.compare(token, record.token_hash); if (isMatch) { tokenRecord = record; break; } } if (!tokenRecord) { return res.status(400).json({ message: 'Invalid or expired password reset token.' }); } // --- Password Strength Check --- const MIN_PASSWORD_SCORE = 3; const strength = zxcvbn(newPassword); if (strength.score < MIN_PASSWORD_SCORE) { logger.warn(`Weak password rejected during password reset for user ID: ${tokenRecord.user_id}. Score: ${strength.score}`); const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]); return res.status(400).json({ message: `New password is too weak. ${feedback || 'Please choose a stronger password.'}`.trim() }); } // Hash the new password before storing it const saltRounds = 10; const hashedPassword = await bcrypt.hash(newPassword, saltRounds); // If token is valid, proceed with password update and strength check await db.updateUserPassword(tokenRecord.user_id, hashedPassword); await db.deleteResetToken(tokenRecord.token_hash); // Invalidate the token after use res.status(200).json({ message: 'Password has been reset successfully.' }); } catch (error) { next(error); } }); // New Route to refresh the access token app.post('/api/auth/refresh-token', async (req: Request, res: Response) => { const { refreshToken } = req.cookies; if (!refreshToken) { return res.status(401).json({ message: 'Refresh token not found.' }); } const user = await db.findUserByRefreshToken(refreshToken); if (!user) { return res.status(403).json({ message: 'Invalid refresh token.' }); } const payload = { id: user.id, email: user.email }; const newAccessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }); res.json({ token: newAccessToken }); }); // Example Protected Route to get user profile // The frontend can call this route on startup to validate a stored JWT app.get('/api/users/profile', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { // If JWT authentication is successful, req.user will contain the user object // from the JwtStrategy's `done` callback. const authenticatedUser = req.user as { id: string; email: string }; logger.info(`Profile requested for user: ${authenticatedUser.email}`); try { const profile = await db.findUserProfileById(authenticatedUser.id); if (!profile) { logger.warn(`No profile found for authenticated user ID: ${authenticatedUser.id}`); return res.status(404).json({ message: 'Profile not found for this user.' }); } res.json(profile); // Return the full profile object } catch (error) { logger.error('Error fetching profile in /api/users/profile:', { error }); res.status(500).json({ message: 'Failed to retrieve user profile.' }); } }); // Protected Route to update user profile (full_name, avatar_url) app.put('/api/users/profile', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { const authenticatedUser = req.user as { id: string; email: string }; const { full_name, avatar_url } = req.body; // Basic validation if (typeof full_name === 'undefined' && typeof avatar_url === 'undefined') { return res.status(400).json({ message: 'At least one field (full_name or avatar_url) must be provided.' }); } logger.info(`Profile update requested for user: ${authenticatedUser.email}`, { fullName: full_name, avatarUrl: avatar_url }); try { const updatedProfile = await db.updateUserProfile(authenticatedUser.id, { full_name, avatar_url }); res.json(updatedProfile); } catch (error) { logger.error('Error updating profile in /api/users/profile:', { error }); res.status(500).json({ message: 'Failed to update user profile.' }); } }); // Protected Route to export all user data app.get('/api/users/data-export', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { const authenticatedUser = req.user as { id: string; email: string }; logger.info(`Data export requested for user: ${authenticatedUser.email}`); try { const userData = await db.exportUserData(authenticatedUser.id); // Set headers to prompt a file download on the client side const date = new Date().toISOString().split('T')[0]; res.setHeader('Content-Disposition', `attachment; filename="flyer-crawler-data-export-${date}.json"`); res.setHeader('Content-Type', 'application/json'); res.json(userData); } catch (error) { logger.error('Error during data export in /api/users/data-export:', { error }); res.status(500).json({ message: 'Failed to export user data.' }); } }); // Protected Route to update user preferences app.put('/api/users/profile/preferences', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { const authenticatedUser = req.user as { id: string; email: string }; const newPreferences = req.body; if (!newPreferences || typeof newPreferences !== 'object') { return res.status(400).json({ message: 'Invalid preferences format. Body must be a JSON object.' }); } logger.info(`Preferences update requested for user: ${authenticatedUser.email}`, { newPreferences }); try { const updatedProfile = await db.updateUserPreferences(authenticatedUser.id, newPreferences); res.json(updatedProfile); } catch (error) { logger.error('Error updating preferences in /api/users/profile/preferences:', { error }); res.status(500).json({ message: 'Failed to update user preferences.' }); } }); // Protected Route to update user password app.put('/api/users/profile/password', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const authenticatedUser = req.user as { id: string; email: string }; const { newPassword } = req.body; if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 6) { return res.status(400).json({ message: 'Password must be a string of at least 6 characters.' }); } // --- Password Strength Check --- const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4) const strength = zxcvbn(newPassword); if (strength.score < MIN_PASSWORD_SCORE) { logger.warn(`Weak password update rejected for user: ${authenticatedUser.email}. Score: ${strength.score}`, { userId: authenticatedUser.id }); // Provide the user with helpful feedback from the strength analysis. const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]); return res.status(400).json({ message: `New password is too weak. ${feedback || 'Please choose a stronger password.'}`.trim() }); } try { // Hash the password only after it has passed the strength check const saltRounds = 10; const hashedPassword = await bcrypt.hash(newPassword, saltRounds); logger.info(`Hashing new, validated password for user: ${authenticatedUser.email}`, { userId: authenticatedUser.id }); await db.updateUserPassword(authenticatedUser.id, hashedPassword); logger.info(`Successfully updated password for user: ${authenticatedUser.email}`); res.status(200).json({ message: 'Password updated successfully.' }); } catch (error) { logger.error('Error during password update:', { error, userId: authenticatedUser.id }); next(error); } }); // Protected Route to delete a user account app.delete('/api/users/account', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response) => { const authenticatedUser = req.user as { id: string; email: string }; const { password } = req.body; if (!password) { return res.status(400).json({ message: 'Password is required for account deletion.' }); } try { // 1. Fetch the user from DB to get their current password hash for verification const userWithHash = await db.findUserByEmail(authenticatedUser.email); if (!userWithHash) { return res.status(404).json({ message: 'User not found.' }); } // 2. Compare the submitted password with the stored hash const isMatch = await bcrypt.compare(password, userWithHash.password_hash); if (!isMatch) { logger.warn(`Account deletion failed for user ${authenticatedUser.email} due to incorrect password.`); return res.status(403).json({ message: 'Incorrect password.' }); } // 3. If password matches, delete the user. The `ON DELETE CASCADE` in your schema will clean up related data. await db.deleteUserById(authenticatedUser.id); logger.warn(`User account deleted successfully: ${authenticatedUser.email}`); res.status(200).json({ message: 'Account deleted successfully.' }); } catch (error) { logger.error('Error during account deletion:', { error }); res.status(500).json({ message: 'Failed to delete account.' }); } }); // --- Admin Routes (Protected for Admins) --- app.get('/api/admin/corrections', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { try { const corrections = await db.getSuggestedCorrections(); res.json(corrections); } catch (error) { logger.error('Error fetching corrections in /api/admin/corrections:', { error }); next(error); } }); app.get('/api/admin/brands', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { try { const brands = await db.getAllBrands(); res.json(brands); } catch (error) { logger.error('Error fetching brands in /api/admin/brands:', { error }); next(error); } }); /** * This endpoint processes a flyer using AI. It uses `optionalAuth` middleware to allow * both authenticated and anonymous users to upload flyers. * - Authenticated users will have their uploads associated with their account. * - Anonymous users can process flyers, but the data won't be tied to a persistent profile. * (Future work could associate it with a temporary session). */ app.post('/api/ai/process-flyer', optionalAuth, upload.array('flyerImages'), async (req: Request, res: Response, next: NextFunction) => { try { if (!req.files || !Array.isArray(req.files) || req.files.length === 0) { return res.status(400).json({ message: 'Flyer image files are required.' }); } // Master items are sent as a JSON string in a separate field const masterItems = JSON.parse(req.body.masterItems); const imagePaths = req.files.map(file => ({ path: file.path, mimetype: file.mimetype })); // Log whether the user is authenticated or anonymous for better tracking. const user = req.user as UserProfile | undefined; const logIdentifier = user ? `user ID: ${user.id}` : 'anonymous user'; logger.info(`Starting AI flyer data extraction for ${imagePaths.length} image(s) for ${logIdentifier}.`); const extractedData = await aiService.extractCoreDataFromFlyerImage(imagePaths, masterItems); logger.info(`Completed AI flyer data extraction. Found ${extractedData.items.length} items.`); res.status(200).json({ data: extractedData }); } catch (error) { logger.error('Error in /api/ai/process-flyer endpoint:', { error }); next(error); } }); /** * This endpoint checks if an image is a flyer. It uses `optionalAuth` to allow * both authenticated and anonymous users to perform this check. */ app.post('/api/ai/check-flyer', optionalAuth, upload.single('image'), async (req, res, next) => { try { if (!req.file) { return res.status(400).json({ message: 'Image file is required.' }); } // This is a placeholder for a server-side implementation of isImageAFlyer // For now, we assume it's a flyer to allow the flow to continue. // A full implementation would call the Gemini API here. logger.info(`Server-side flyer check for file: ${req.file.originalname}`); res.status(200).json({ is_flyer: true }); // Stubbed response } catch (error) { next(error); } }); app.post('/api/ai/extract-address', optionalAuth, upload.single('image'), async (req, res, next) => { try { if (!req.file) { return res.status(400).json({ message: 'Image file is required.' }); } // Placeholder for server-side address extraction logger.info(`Server-side address extraction for file: ${req.file.originalname}`); res.status(200).json({ address: "123 AI Street, Server City" }); // Stubbed response } catch (error) { next(error); } }); app.post('/api/ai/extract-logo', optionalAuth, upload.array('images'), async (req, res, next) => { try { if (!req.files || !Array.isArray(req.files) || req.files.length === 0) { return res.status(400).json({ message: 'Image files are required.' }); } // Placeholder for server-side logo extraction logger.info(`Server-side logo extraction for ${req.files.length} image(s).`); res.status(200).json({ store_logo_base_64: null }); // Stubbed response } catch (error) { next(error); } }); app.post('/api/ai/quick-insights', passport.authenticate('jwt', { session: false }), async (req, res, next) => { try { // Placeholder for server-side quick insights 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 } catch (error) { next(error); } }); app.post('/api/ai/deep-dive', passport.authenticate('jwt', { session: false }), async (req, res, next) => { try { // Placeholder for server-side deep dive 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 } catch (error) { next(error); } }); app.post('/api/ai/search-web', passport.authenticate('jwt', { session: false }), async (req, res, next) => { try { // Placeholder for server-side web search logger.info(`Server-side web search requested.`); res.status(200).json({ text: "The web says this is good.", sources: [] }); // Stubbed response } catch (error) { next(error); } }); app.post('/api/ai/plan-trip', passport.authenticate('jwt', { session: false }), async (req, res, next) => { try { // Placeholder for server-side trip planning logger.info(`Server-side trip planning requested.`); res.status(200).json({ text: "Here is your trip plan.", sources: [] }); // Stubbed response } catch (error) { next(error); } }); app.post('/api/ai/generate-image', passport.authenticate('jwt', { session: false }), async (req, res, next) => { try { // Placeholder for server-side image generation logger.info(`Server-side image generation requested.`); res.status(200).json({ base64Image: "" }); // Stubbed response } catch (error) { next(error); } }); app.post('/api/ai/generate-speech', passport.authenticate('jwt', { session: false }), async (req, res, next) => { try { // Placeholder for server-side speech generation logger.info(`Server-side speech generation requested.`); res.status(200).json({ base64Audio: "" }); // Stubbed response } catch (error) { next(error); } }); app.get('/api/admin/stats', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { try { const stats = await db.getApplicationStats(); res.json(stats); } catch (error) { logger.error('Error fetching application stats in /api/admin/stats:', { error }); next(error); } }); app.get('/api/admin/stats/daily', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { try { const dailyStats = await db.getDailyStatsForLast30Days(); res.json(dailyStats); } catch (error) { logger.error('Error fetching daily stats in /api/admin/stats/daily:', { error }); next(error); } }); app.post('/api/admin/corrections/:id/approve', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { const correctionId = parseInt(req.params.id, 10); try { await db.approveCorrection(correctionId); res.status(200).json({ message: 'Correction approved successfully.' }); } catch (error) { logger.error(`Error approving correction ${correctionId}:`, { error }); next(error); } }); app.post('/api/admin/corrections/:id/reject', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { const correctionId = parseInt(req.params.id, 10); try { await db.rejectCorrection(correctionId); res.status(200).json({ message: 'Correction rejected successfully.' }); } catch (error) { logger.error(`Error rejecting correction ${correctionId}:`, { error }); next(error); } }); app.put('/api/admin/corrections/:id', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { const correctionId = parseInt(req.params.id, 10); const { suggested_value } = req.body; if (!suggested_value) { return res.status(400).json({ message: 'A new suggested_value is required.' }); } try { const updatedCorrection = await db.updateSuggestedCorrection(correctionId, suggested_value); res.status(200).json(updatedCorrection); } catch (error) { next(error); } }); app.put('/api/admin/recipes/:id/status', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { const recipeId = parseInt(req.params.id, 10); const { status } = req.body; if (!status || !['private', 'pending_review', 'public', 'rejected'].includes(status)) { return res.status(400).json({ message: 'A valid status (private, pending_review, public, rejected) is required.' }); } try { const updatedRecipe = await db.updateRecipeStatus(recipeId, status); res.status(200).json(updatedRecipe); } catch (error) { next(error); } }); app.post('/api/admin/brands/:id/logo', passport.authenticate('jwt', { session: false }), isAdmin, upload.single('logoImage'), async (req: Request, res: Response, next: NextFunction) => { const brandId = parseInt(req.params.id, 10); try { if (!req.file) { return res.status(400).json({ message: 'Logo image file is required.' }); } const logoUrl = `/assets/${req.file.filename}`; await db.updateBrandLogo(brandId, logoUrl); logger.info(`Brand logo updated for brand ID: ${brandId}`, { brandId, logoUrl }); res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl }); } catch (error) { next(error); } }); app.get('/api/admin/unmatched-items', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { try { const items = await db.getUnmatchedFlyerItems(); res.json(items); } catch (error) { logger.error('Error fetching unmatched items in /api/admin/unmatched-items:', { error }); next(error); } }); app.put('/api/admin/comments/:id/status', passport.authenticate('jwt', { session: false }), isAdmin, async (req: Request, res: Response, next: NextFunction) => { const commentId = parseInt(req.params.id, 10); const { status } = req.body; if (!status || !['visible', 'hidden', 'reported'].includes(status)) { return res.status(400).json({ message: 'A valid status (visible, hidden, reported) is required.' }); } try { const updatedComment = await db.updateRecipeCommentStatus(commentId, status); res.status(200).json(updatedComment); } catch (error) { next(error); } }); // --- Receipt Processing Routes --- app.post('/api/receipts/upload', passport.authenticate('jwt', { session: false }), upload.single('receiptImage'), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; try { if (!req.file) { return res.status(400).json({ message: 'Receipt image file is required.' }); } // For now, we'll just create the receipt record. The AI processing would be triggered here. // In a real-world scenario, you'd add this to a job queue. const receiptImageUrl = `/assets/${req.file.filename}`; const newReceipt = await db.createReceipt(user.id, receiptImageUrl); // --- REAL AI PROCESSING --- // In a real-world, scalable application, this AI processing step should be // offloaded to a background job queue (e.g., using BullMQ, RabbitMQ) // to avoid making the user wait for a long-running HTTP request. // For this implementation, we'll run it directly. logger.info(`Starting AI receipt processing for receipt ID: ${newReceipt.id}`); const extractedItems = await aiService.extractItemsFromReceiptImage(req.file.path, req.file.mimetype); await db.processReceiptItems(newReceipt.id, JSON.stringify(extractedItems), extractedItems); logger.info(`Completed AI receipt processing for receipt ID: ${newReceipt.id}. Found ${extractedItems.length} items.`); res.status(201).json({ message: 'Receipt uploaded and processed successfully.', receipt: newReceipt }); } catch (error) { next(error); } }); app.get('/api/receipts/:id/deals', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => { const user = req.user as { id: string }; const receiptId = parseInt(req.params.id, 10); try { // --- Ownership Check --- const owner = await db.findReceiptOwner(receiptId); if (!owner) { return res.status(404).json({ message: 'Receipt not found.' }); } if (owner.user_id !== user.id) { logger.warn(`User ${user.id} attempted to access unauthorized receipt ${receiptId}.`); return res.status(403).json({ message: 'Forbidden: You do not have access to this receipt.' }); } const deals = await db.findDealsForReceipt(receiptId); res.json(deals); } catch (error) { next(error); } }); // --- Error Handling and Server Startup --- // Basic error handling middleware app.use((err: Error, req: Request, res: Response, _next: NextFunction) => { logger.error('Unhandled application error:', { error: err.stack }); res.status(500).send('Something broke!'); }); const PORT = process.env.PORT || 3001; app.listen(PORT, () => { logger.info(`Authentication server started on port ${PORT}`); });