Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 51m31s
1706 lines
68 KiB
TypeScript
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}`);
|
|
}); |