Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions backend/routes/recommendations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
const express = require('express');
const router = express.Router();
const { GoogleGenerativeAI } = require('@google/generative-ai');
const Meal = require('../models/Meal');
const logger = require('../utils/logger');
const APIKeyManager = require('../utils/apiKeyManager');
const { recommendationPrompt } = require('../utils/prompts');

const apiKeyManager = new APIKeyManager();

const DAILY_TARGETS = {
calories: 2000,
protein: 50,
carbs: 250,
fat: 65,
};

router.get('/recommendations', async (req, res) => {
const requestId = Date.now().toString();

try {
logger.info(`Recommendation request started: ${requestId}`);

const now = new Date();
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());

const todaysMeals = await Meal.find({
createdAt: { $gte: startOfDay },
}).lean();

const dailyTotals = todaysMeals.reduce(
(acc, meal) => {
acc.calories += meal.calories || 0;
acc.protein += meal.macronutrients?.protein || 0;
acc.carbs += meal.macronutrients?.carbs || 0;
acc.fat += meal.macronutrients?.fat || 0;
return acc;
},
{ calories: 0, protein: 0, carbs: 0, fat: 0 }
);

const remaining = {
calories: Math.max(0, DAILY_TARGETS.calories - dailyTotals.calories),
protein: Math.max(0, DAILY_TARGETS.protein - dailyTotals.protein),
carbs: Math.max(0, DAILY_TARGETS.carbs - dailyTotals.carbs),
fat: Math.max(0, DAILY_TARGETS.fat - dailyTotals.fat),
};

const recentFoods = todaysMeals
.map(meal => meal.foodName)
.filter(Boolean);

const prompt = recommendationPrompt(
remaining.calories,
remaining.protein,
remaining.carbs,
remaining.fat,
recentFoods
);

const apiKey = await apiKeyManager.getValidKey();
const genAI = new GoogleGenerativeAI(apiKey);
const model = genAI.getGenerativeModel({ model: 'gemini-flash-latest' });

const result = await model.generateContent([prompt]);
const response = await result.response;
let text = response.text();

text = text
.replace(/```json/g, '')
.replace(/```/g, '')
.trim();

let recipes;
try {
recipes = JSON.parse(text);
logger.info(`Recommendations generated successfully: ${requestId}`);
} catch (parseError) {
logger.error(`Failed to parse recommendation response: ${requestId}`, parseError);
return res.status(500).json({
error: 'Failed to parse AI recommendations',
requestId,
});
}

if (!Array.isArray(recipes)) {
recipes = [recipes];
}

res.json({
recipes,
dailyTotals,
remaining,
mealsLogged: todaysMeals.length,
});
} catch (error) {
if (error.message === 'All API keys have exceeded rate limits') {
logger.error(`API rate limit exceeded: ${requestId}`, error);
return res.status(429).json({
error: 'Service temporarily unavailable. Please try again later.',
requestId,
});
}

logger.error(`Recommendation generation failed: ${requestId}`, error);
res.status(500).json({
error: 'Failed to generate recommendations',
requestId,
});
}
});

module.exports = router;
32 changes: 10 additions & 22 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,17 @@ require('dotenv').config();
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');

const app = express();
app.use(cookieParser());
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const multer = require('multer');
const path = require('path');
const chatRoute = require('./routes/chat.route');
const fs = require('fs');
const chatRoute = require('./routes/chat.route');

const fs = require('fs');

const logger = require('./utils/logger');

const authRoutes = require('./routes/authRoutes');
const chatRoute = require('./routes/chat.route');

const app = express();

// Validate required environment variables
const requiredEnvVars = ['GEMINI_API_KEY', 'MONGO_URI', 'JWT_SECRET'];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
Expand All @@ -31,25 +24,21 @@ for (const envVar of requiredEnvVars) {

const PORT = process.env.PORT || 5000;

// Security middleware
app.use(helmet());

// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Too many requests from this IP',
});
app.use(limiter);

// Stricter rate limit for upload endpoint
const uploadLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 10 uploads per 15 minutes
max: 10,
message: 'Upload rate limit exceeded',
});

// CORS configuration
const allowedOrigins = (
process.env.CORS_ALLOWED_ORIGINS ||
'http://localhost:3000,http://localhost:5173'
Expand All @@ -60,7 +49,6 @@ const allowedOrigins = (

const corsOptions = {
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);

if (
Expand Down Expand Up @@ -89,7 +77,7 @@ app.use(cookieParser());
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));

app.use('/api/auth', authRoutes);
// Ensure uploads directory exists

const uploadsDir = path.join(__dirname, 'uploads');
try {
if (!fs.existsSync(uploadsDir)) {
Expand All @@ -100,15 +88,16 @@ try {
process.exit(1);
}

// Routes
const analyzeRoutes = require('./routes/analyze');
const recommendationsRoutes = require('./routes/recommendations');

app.use('/api', uploadLimiter, analyzeRoutes);
app.use('/api', recommendationsRoutes);

app.get('/', (req, res) => {
res.send('NutriLens Backend is running');
});

// Global Error Handler
app.use((err, req, res, _next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
Expand All @@ -129,7 +118,6 @@ app.use((err, req, res, _next) => {
res.status(500).json({ error: 'Internal server error' });
});

// Database Connection and Server Start
const connectDB = require('./config/db');

const startServer = async () => {
Expand Down
46 changes: 42 additions & 4 deletions backend/utils/prompts.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
// Text chat prompt
const chatPrompt = message => `You are NutriBot, a helpful nutrition assistant.
User Message: "${message}"

Respond in valid JSON format ONLY:
{
"text": "Your helpful response text here...",
"report": { // Optional: include ONLY if user describes a specific meal to analyze
"report": {
"carbs": 0,
"protein": 0,
"fats": 0
Expand All @@ -21,7 +20,6 @@ Rules:
- Don't force a report if not applicable.
`;

// Image analysis prompt
const analysisPrompt =
quantity => `Analyze this food image thoroughly. Identify the food item(s).

Expand Down Expand Up @@ -71,13 +69,53 @@ Return ONLY valid JSON in the following format (all numeric values MUST be numbe
}
Notes:
- All gram values should be in grams (g)
- Vitamins and minerals in milligrams (mg) or appropriate units (mcg for certain vitamins if standard, but preferably normalize to mg or specify unit if implicit constraints allow - however schema implies Number so stick to standard numerical values, e.g. mg for Sodium/Potassium/Calcium/Iron/Magnesium/Zinc. Vitamin A/D/B12/C usually mg or mcg. Provide best estimate in standard units.)
- Vitamins and minerals in milligrams (mg) or appropriate units
- Percentages should be whole numbers (0-100)
- healthScore should be 0-100
- Be accurate with portion size estimation based on the User Context provided.
- Provide NON-ZERO estimates for micronutrients if reasonable trace amounts exist. Do not just zero them out unless completely absent.`;

const recommendationPrompt = (remainingCalories, remainingProtein, remainingCarbs, remainingFat, recentFoods) => {
const foodContext = recentFoods && recentFoods.length > 0
? `The user has recently eaten: ${recentFoods.join(', ')}. Suggest different foods to add variety.`
: 'The user has not logged any meals today yet. Suggest balanced meals for the day.';

return `You are a professional nutritionist AI. ${foodContext}

The user's remaining daily nutritional targets are:
- Calories: ${remainingCalories} kcal
- Protein: ${remainingProtein} g
- Carbs: ${remainingCarbs} g
- Fat: ${remainingFat} g

Suggest exactly 3 healthy, practical recipes that collectively help the user meet their remaining nutritional goals. Each recipe should be simple to prepare at home.

Return ONLY valid JSON as an array of exactly 3 objects in the following format (all numeric values MUST be numbers, not strings):
[
{
"title": "Recipe Name",
"prepTime": "15 mins",
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0,
"ingredients": ["ingredient 1 with quantity", "ingredient 2 with quantity"],
"instructions": ["Step 1 description", "Step 2 description"],
"tags": ["high-protein", "low-carb"]
}
]

Rules:
- Each recipe should target roughly one-third of the remaining daily macros.
- Tags should be relevant descriptors like "high-protein", "low-carb", "low-fat", "quick", "vegan", "gluten-free", etc.
- Keep instructions concise but actionable (3-6 steps per recipe).
- Include 4-8 ingredients per recipe with specific quantities.
- Prep time should be realistic.
- All calorie values in kcal, macros in grams.`;
};

module.exports = {
chatPrompt,
analysisPrompt,
recommendationPrompt,
};
6 changes: 2 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Navbar } from './components/Navbar';
import { HistorySidebar } from './components/HistorySidebar';
import { Home } from './pages/Home';
import { Analysis } from './pages/Analysis';
import { Recipes } from './pages/Recipes';
import { getHistory, clearHistory } from './api';
import type { MealData } from './api';
import Footer from './components/Footer';
Expand All @@ -29,7 +30,6 @@ const App: React.FC = () => {

fetchHistory();

// Listen for history updates from other components
const handleHistoryUpdate = () => {
fetchHistory();
};
Expand All @@ -51,15 +51,14 @@ const App: React.FC = () => {

return (
<div className='min-h-screen flex flex-col transition-colors duration-300'>
{/* Navbar */}
<Navbar showHistory={showHistory} setShowHistory={setShowHistory} />

{/* Main Content */}
<main className='flex-1 max-w-4xl mx-auto px-4 pt-32'>
<AnimatePresence mode='wait'>
<Routes location={location} key={location.pathname}>
<Route path='/' element={<Home />} />
<Route path='/analysis' element={<Analysis />} />
<Route path='/recipes' element={<Recipes />} />
</Routes>
</AnimatePresence>

Expand All @@ -75,7 +74,6 @@ const App: React.FC = () => {
/>
</main>

{/* Footer */}
<Footer />
<Chatbot />
<ScrollToTop />
Expand Down
24 changes: 20 additions & 4 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,9 @@ export interface MealData {
benefits: string[];
concerns: string[];
};
// flattened properties for backward compatibility or direct access if needed
fat?: number; // legacy
protein?: number; // legacy
carbs?: number; // legacy
fat?: number;
protein?: number;
carbs?: number;
analysis: string;
recommendation: string;
imagePath: string;
Expand All @@ -73,6 +72,18 @@ export interface HistoryResponse {
};
}

export interface Recipe {
title: string;
prepTime: string;
calories: number;
protein: number;
carbs: number;
fat: number;
ingredients: string[];
instructions: string[];
tags: string[];
}

import { compressImage } from './utils/imageOptimizer';

export const analyzeImage = async (file: File, quantity?: string): Promise<MealData> => {
Expand Down Expand Up @@ -134,3 +145,8 @@ export const sendChatMessage = async (
const response = await axios.post(`${API_URL}/chat`, { message });
return response.data;
};

export const getRecommendations = async (): Promise<Recipe[]> => {
const response = await axios.get(`${API_URL}/recommendations`);
return response.data.recipes;
};
Loading
Loading