Files
flyer-crawler.projectium.com/server.ts
Torben Sorensen 69be398cd9
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 51m31s
testing hehehe
2025-11-20 22:11:09 -08:00

1706 lines
68 KiB
TypeScript

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 <token>' 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<ShoppingListItem> = 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}`);
});