build: add TypeScript configuration and generate declaration files

- Add tsconfig.json for TypeScript compilation with declaration and source map generation
- Generate .d.ts declaration files for all modules, services, controllers, and models
- Update package.json with NestJS dependencies and TypeScript development tools
- Include database files in the distribution output for persistence
This commit is contained in:
Jp
2026-01-31 09:00:26 +08:00
parent 0fa0343798
commit f521970a65
174 changed files with 7205 additions and 1633 deletions

67
src/app.controller.ts Normal file
View File

@@ -0,0 +1,67 @@
import { Controller, Get, Post, Body, Res, UseGuards, Req, Render, Redirect } from '@nestjs/common';
import { Response, Request } from 'express';
import { LocalAuthGuard } from './auth/guards/local-auth.guard';
import { UsersService } from './users/users.service';
import { AuthenticatedGuard } from './auth/guards/authenticated.guard';
@Controller()
export class AppController {
constructor(private usersService: UsersService) {}
@Get()
root(@Req() req, @Res() res: Response) {
if (req.isAuthenticated()) {
return res.redirect('/dashboard');
} else {
return res.redirect('/login');
}
}
@Get('login')
@Render('login')
login() {
return {};
}
@UseGuards(LocalAuthGuard)
@Post('login')
loginPost(@Res() res: Response) {
res.redirect('/dashboard');
}
@Get('register')
@Render('register')
register() {
return {};
}
@Post('register')
async registerPost(@Body() body, @Res() res: Response, @Req() req) {
const { username, password, name } = body;
try {
const existingUser = await this.usersService.findOne(username);
if (existingUser) {
req.flash('error', 'Username already exists');
return res.redirect('/register');
}
await this.usersService.create({ username, password, name });
req.flash('success_msg', 'You are now registered and can log in');
res.redirect('/login');
} catch (err) {
console.error(err);
req.flash('error', 'Error registering user');
res.redirect('/register');
}
}
@Get('logout')
logout(@Req() req, @Res() res: Response) {
req.logout((err) => {
if (err) {
console.error(err);
}
req.flash('success_msg', 'You are logged out');
res.redirect('/login');
});
}
}

40
src/app.module.ts Normal file
View File

@@ -0,0 +1,40 @@
import { Module, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { AppController } from './app.controller';
import { UtilsModule } from './utils/utils.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { MealsModule } from './meals/meals.module';
import { GoalsModule } from './goals/goals.module';
import { FoodsModule } from './foods/foods.module';
import { MealPlannerModule } from './meal-planner/meal-planner.module';
import { LocalsMiddleware } from './common/middleware/locals.middleware';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
AuthModule,
UsersModule,
UtilsModule,
DashboardModule,
MealsModule,
GoalsModule,
FoodsModule,
MealPlannerModule,
],
controllers: [AppController],
providers: [],
})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LocalsMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

13
src/auth/auth.module.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';
import { SessionSerializer } from './session.serializer';
@Module({
imports: [UsersModule, PassportModule.register({ session: true })],
providers: [AuthService, LocalStrategy, SessionSerializer],
exports: [AuthService],
})
export class AuthModule {}

17
src/auth/auth.service.ts Normal file
View File

@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Injectable()
export class AuthService {
constructor(private usersService: UsersService) {}
async validateUser(username: string, pass: string): Promise<any> {
const user = await this.usersService.findOne(username);
if (user && user.validPassword(pass)) {
// Return user object without password? Or just the user model.
// Passport serializer will handle the rest.
return user;
}
return null;
}
}

View File

@@ -0,0 +1,9 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class AuthenticatedGuard implements CanActivate {
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
return request.isAuthenticated();
}
}

View File

@@ -0,0 +1,12 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
async canActivate(context: ExecutionContext) {
const result = (await super.canActivate(context)) as boolean;
const request = context.switchToHttp().getRequest();
await super.logIn(request);
return result;
}
}

View File

@@ -0,0 +1,19 @@
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
async validate(username: string, pass: string): Promise<any> {
const user = await this.authService.validateUser(username, pass);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}

View File

@@ -0,0 +1,19 @@
import { PassportSerializer } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Injectable()
export class SessionSerializer extends PassportSerializer {
constructor(private readonly usersService: UsersService) {
super();
}
serializeUser(user: any, done: Function) {
done(null, user.id);
}
async deserializeUser(userId: any, done: Function) {
const user = await this.usersService.findById(userId);
done(null, user);
}
}

View File

@@ -0,0 +1,15 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LocalsMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const reqAny = req as any;
res.locals.current_user = reqAny.user;
res.locals.success_msg = reqAny.flash('success_msg');
res.locals.error_msg = reqAny.flash('error_msg');
res.locals.error = reqAny.flash('error');
res.locals.path = req.path;
next();
}
}

View File

@@ -0,0 +1,120 @@
import { Controller, Get, Render, UseGuards, Req, Res } from '@nestjs/common';
import { AuthenticatedGuard } from '../auth/guards/authenticated.guard';
import { UtilsService } from '../utils/utils.service';
import { UserGoal } from '../models/user-goal.model';
import { WeightLog } from '../models/weight-log.model';
import { InjectModel } from '@nestjs/sequelize';
import { Response } from 'express';
@Controller('dashboard')
@UseGuards(AuthenticatedGuard)
export class DashboardController {
constructor(
private utilsService: UtilsService,
@InjectModel(UserGoal) private userGoalModel: typeof UserGoal,
@InjectModel(WeightLog) private weightLogModel: typeof WeightLog,
) {}
@Get()
@Render('dashboard')
async getDashboard(@Req() req, @Res() res: Response) {
try {
const today = new Date();
const dateStr = today.toISOString().split('T')[0];
// Get daily totals
const nutrition = await this.utilsService.calculateDailyTotals(req.user.id, dateStr);
const water = await this.utilsService.calculateWaterTotal(req.user.id, dateStr);
// Get user goals
let goals = await this.userGoalModel.findOne({ where: { UserId: req.user.id } });
if (!goals) {
goals = await this.userGoalModel.create({
UserId: req.user.id,
target_protein_g: 150,
target_carbs_g: 200,
target_fat_g: 60,
target_water_ml: 2000,
} as any);
}
// Get weight info
const weightLogToday = await this.weightLogModel.findOne({
where: { UserId: req.user.id, date: dateStr },
});
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split('T')[0];
const weightLogYesterday = await this.weightLogModel.findOne({
where: { UserId: req.user.id, date: yesterdayStr },
});
let weightChange = null;
if (weightLogToday && weightLogYesterday) {
weightChange = weightLogToday.weight_kg - weightLogYesterday.weight_kg;
}
// Calculate remaining
const remaining = {
calories: req.user.target_daily_calories - nutrition.calories,
protein: goals.target_protein_g - nutrition.protein,
carbs: goals.target_carbs_g - nutrition.carbs,
fat: goals.target_fat_g - nutrition.fat,
water: goals.target_water_ml - water.total_ml,
};
// Get macro percentages
const macro_percentages = this.utilsService.getMacroPercentages(
nutrition.protein,
nutrition.carbs,
nutrition.fat,
);
// Get suggestions
const suggestions = this.utilsService.suggestFoodsForMacros(
remaining.protein,
remaining.carbs,
remaining.fat,
);
// Get trends
const calorie_trend = await this.utilsService.getCalorieTrend(req.user.id);
const weight_trend = await this.utilsService.getWeightTrend(req.user.id);
return {
user: req.user,
current_user: req.user,
nutrition,
water,
goals,
weightLogToday,
weightChange,
remaining,
macro_percentages,
suggestions,
calorie_trend,
weight_trend,
};
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading dashboard');
// Return minimal data to render the view without crashing
return {
user: req.user,
current_user: req.user,
nutrition: { calories: 0, protein: 0, carbs: 0, fat: 0, meals: [] },
water: { total_ml: 0, logs: [] },
goals: null,
weightLogToday: null,
weightChange: null,
remaining: { calories: 0, protein: 0, carbs: 0, fat: 0, water: 0 },
macro_percentages: { protein: 0, carbs: 0, fat: 0 },
suggestions: [],
calorie_trend: [],
weight_trend: [],
};
}
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { DashboardController } from './dashboard.controller';
import { ProgressController } from './progress.controller';
import { UserGoal } from '../models/user-goal.model';
import { WeightLog } from '../models/weight-log.model';
import { UtilsModule } from '../utils/utils.module';
@Module({
imports: [
SequelizeModule.forFeature([UserGoal, WeightLog]),
UtilsModule,
],
controllers: [DashboardController, ProgressController],
})
export class DashboardModule {}

View File

@@ -0,0 +1,63 @@
import { Controller, Get, Render, UseGuards, Req, Query } from '@nestjs/common';
import { AuthenticatedGuard } from '../auth/guards/authenticated.guard';
import { UtilsService } from '../utils/utils.service';
@Controller('progress')
@UseGuards(AuthenticatedGuard)
export class ProgressController {
constructor(private utilsService: UtilsService) {}
@Get()
@Render('progress')
async getProgress(@Req() req, @Query('days') daysQuery: string) {
try {
const days = parseInt(daysQuery) || 30;
const weightTrend = await this.utilsService.getWeightTrend(req.user.id, days);
const calorieTrend = await this.utilsService.getCalorieTrend(req.user.id, days);
let avgCalories = 0,
avgProtein = 0,
avgCarbs = 0,
avgFat = 0;
if (calorieTrend.length > 0) {
avgCalories = calorieTrend.reduce((sum, d) => sum + d.calories, 0) / calorieTrend.length;
avgProtein = calorieTrend.reduce((sum, d) => sum + d.protein, 0) / calorieTrend.length;
avgCarbs = calorieTrend.reduce((sum, d) => sum + d.carbs, 0) / calorieTrend.length;
avgFat = calorieTrend.reduce((sum, d) => sum + d.fat, 0) / calorieTrend.length;
}
let weightChange = null;
if (weightTrend.length >= 2) {
weightChange = weightTrend[weightTrend.length - 1].weight_kg - weightTrend[0].weight_kg;
}
return {
user: req.user,
weight_trend: weightTrend,
calorie_trend: calorieTrend,
avg_calories: Math.round(avgCalories),
avg_protein: Math.round(avgProtein),
avg_carbs: Math.round(avgCarbs),
avg_fat: Math.round(avgFat),
weight_change: weightChange,
days,
};
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading progress');
return {
user: req.user,
weight_trend: [],
calorie_trend: [],
avg_calories: 0,
avg_protein: 0,
avg_carbs: 0,
avg_fat: 0,
weight_change: null,
days: 30,
};
}
}
}

View File

@@ -0,0 +1,44 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from '../models/user.model';
import { FoodItem } from '../models/food-item.model';
import { Meal } from '../models/meal.model';
import { MealFood } from '../models/meal-food.model';
import { WaterLog } from '../models/water-log.model';
import { WeightLog } from '../models/weight-log.model';
import { MealPlan } from '../models/meal-plan.model';
import { PlannedFood } from '../models/planned-food.model';
import { UserGoal } from '../models/user-goal.model';
import { DailySummary } from '../models/daily-summary.model';
import { APICache } from '../models/api-cache.model';
@Module({
imports: [
SequelizeModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
dialect: 'sqlite',
storage: configService.get<string>('DATABASE_URL') ? configService.get<string>('DATABASE_URL').replace('sqlite://', '') : 'data/calorie_tracker.db',
models: [
User,
FoodItem,
Meal,
MealFood,
WaterLog,
WeightLog,
MealPlan,
PlannedFood,
UserGoal,
DailySummary,
APICache,
],
autoLoadModels: true,
synchronize: true,
logging: false,
}),
inject: [ConfigService],
}),
],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Query, Render, UseGuards, Req } from '@nestjs/common';
import { AuthenticatedGuard } from '../auth/guards/authenticated.guard';
import { FoodsService } from './foods.service';
@Controller('foods')
@UseGuards(AuthenticatedGuard)
export class FoodsController {
constructor(private foodsService: FoodsService) {}
@Get()
@Render('foods')
async getFoods(@Req() req, @Query('category') category: string, @Query('q') q: string) {
try {
const data = await this.foodsService.getFoods(category, q);
return {
user: req.user,
...data,
};
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading foods');
return {
user: req.user,
filipino_foods: [],
other_foods: [],
categories: [],
current_category: 'all',
search_query: '',
};
}
}
}

12
src/foods/foods.module.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { FoodsController } from './foods.controller';
import { FoodsService } from './foods.service';
import { FoodItem } from '../models/food-item.model';
@Module({
imports: [SequelizeModule.forFeature([FoodItem])],
controllers: [FoodsController],
providers: [FoodsService],
})
export class FoodsModule {}

View File

@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import { FoodItem } from '../models/food-item.model';
@Injectable()
export class FoodsService {
constructor(
@InjectModel(FoodItem) private foodItemModel: typeof FoodItem,
) {}
async getFoods(category: string = 'all', searchQuery: string = '') {
const whereClause: any = {};
if (category !== 'all') {
whereClause.category = category;
}
if (searchQuery) {
whereClause[Op.or] = [
{ name: { [Op.like]: `%${searchQuery}%` } },
{ name_tagalog: { [Op.like]: `%${searchQuery}%` } },
];
}
const filipinoWhere = { ...whereClause, is_filipino: true };
const otherWhere = { ...whereClause, is_filipino: false };
const filipinoFoods = await this.foodItemModel.findAll({
where: filipinoWhere,
order: [['name', 'ASC']],
});
const otherFoods = await this.foodItemModel.findAll({
where: otherWhere,
limit: 20,
order: [['name', 'ASC']],
});
const categories = ['all', 'kanin', 'ulam', 'sabaw', 'gulay', 'meryenda', 'almusal'];
return {
filipino_foods: filipinoFoods,
other_foods: otherFoods,
categories,
current_category: category,
search_query: searchQuery,
};
}
}

View File

@@ -0,0 +1,46 @@
import { Controller, Get, Post, Body, Req, Res, UseGuards, Render } from '@nestjs/common';
import { Response } from 'express';
import { AuthenticatedGuard } from '../auth/guards/authenticated.guard';
import { GoalsService } from './goals.service';
@Controller('goals')
@UseGuards(AuthenticatedGuard)
export class GoalsController {
constructor(private goalsService: GoalsService) {}
@Get()
@Render('goals')
async getGoals(@Req() req) {
try {
const data = await this.goalsService.getGoals(req.user.id);
return {
user: req.user,
goals: data.goals,
bmr: data.bmr,
tdee: data.tdee,
};
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading goals');
return {
user: req.user,
goals: null,
bmr: null,
tdee: null,
};
}
}
@Post()
async updateGoals(@Req() req, @Body() body, @Res() res: Response) {
try {
await this.goalsService.updateGoals(req.user.id, body);
req.flash('success_msg', 'Goals updated successfully!');
res.redirect('/goals');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error updating goals');
res.redirect('/goals');
}
}
}

17
src/goals/goals.module.ts Normal file
View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { GoalsController } from './goals.controller';
import { GoalsService } from './goals.service';
import { User } from '../models/user.model';
import { UserGoal } from '../models/user-goal.model';
import { UtilsModule } from '../utils/utils.module';
@Module({
imports: [
SequelizeModule.forFeature([User, UserGoal]),
UtilsModule,
],
controllers: [GoalsController],
providers: [GoalsService],
})
export class GoalsModule {}

100
src/goals/goals.service.ts Normal file
View File

@@ -0,0 +1,100 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { User } from '../models/user.model';
import { UserGoal } from '../models/user-goal.model';
import { UtilsService } from '../utils/utils.service';
@Injectable()
export class GoalsService {
constructor(
@InjectModel(User) private userModel: typeof User,
@InjectModel(UserGoal) private userGoalModel: typeof UserGoal,
private utilsService: UtilsService,
) {}
async getGoals(userId: number) {
const user = await this.userModel.findByPk(userId);
const userGoals = await this.userGoalModel.findOne({ where: { UserId: userId } });
let bmr = null;
let tdee = null;
if (user && user.weight_kg && user.height_cm && user.age) {
bmr = this.utilsService.calculateBMR(
user.weight_kg,
user.height_cm,
user.age,
user.gender || 'male',
);
tdee = this.utilsService.calculateTDEE(bmr, user.activity_level || 'moderate');
}
return {
goals: userGoals,
bmr,
tdee,
user,
};
}
async updateGoals(userId: number, data: any) {
// Update user info
const user = await this.userModel.findByPk(userId);
if (!user) {
throw new Error('User not found');
}
user.age = parseInt(data.age) || 25;
user.gender = data.gender || 'male';
user.height_cm = parseFloat(data.height_cm) || 170;
user.weight_kg = parseFloat(data.weight_kg) || 70;
user.activity_level = data.activity_level || 'moderate';
// Calculate targets
const bmr = this.utilsService.calculateBMR(
user.weight_kg,
user.height_cm,
user.age,
user.gender,
);
const tdee = this.utilsService.calculateTDEE(bmr, user.activity_level);
const goalType = data.goal_type || 'recomp';
let targetCalories;
if (goalType === 'weight_loss') {
targetCalories = tdee - 500;
} else if (goalType === 'muscle_gain') {
targetCalories = tdee + 300;
} else {
targetCalories = tdee;
}
user.target_daily_calories = Math.round(targetCalories);
await user.save();
// Update or create goals
let userGoals = await this.userGoalModel.findOne({ where: { UserId: userId } });
if (!userGoals) {
userGoals = await this.userGoalModel.create({ UserId: userId } as any);
}
userGoals.goal_type = goalType;
userGoals.target_weight_kg = parseFloat(data.target_weight_kg) || 70;
// Calculate macros
const macros = this.utilsService.calculateMacroTargets(user.weight_kg, goalType);
userGoals.target_protein_g = macros.protein_g;
userGoals.target_carbs_g = macros.carbs_g;
userGoals.target_fat_g = macros.fat_g;
userGoals.target_water_ml = parseInt(data.target_water_ml) || 2000;
await userGoals.save();
return {
success: true,
user,
goals: userGoals,
};
}
}

60
src/main.ts Normal file
View File

@@ -0,0 +1,60 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import * as session from 'express-session';
import * as passport from 'passport';
import * as flash from 'express-flash';
import * as expressLayouts from 'express-ejs-layouts';
import * as methodOverride from 'method-override';
import * as path from 'path';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const SQLiteStore = require('connect-sqlite3')(session);
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Static assets
app.useStaticAssets(path.join(__dirname, '..', 'public'));
// Views
app.setBaseViewsDir(path.join(__dirname, '..', 'views'));
app.setViewEngine('ejs');
// Layouts
app.use(expressLayouts);
app.set('layout', 'layout');
// Method override
app.use(methodOverride('_method'));
// Body parsing (built-in in NestJS, but ensuring extended urlencoded)
app.useBodyParser('urlencoded', { extended: true });
// Session
app.use(
session({
store: new SQLiteStore({ db: 'sessions.db', dir: './data' }),
secret: process.env.SECRET_KEY || 'secret',
resave: false,
saveUninitialized: false,
cookie: { maxAge: 30 * 24 * 60 * 60 * 1000 }, // 30 days
}),
);
// Passport
app.use(passport.initialize());
app.use(passport.session());
// Flash
app.use(flash());
// Global variables (helper functions)
const expressApp = app.getHttpAdapter().getInstance();
expressApp.locals.round = Math.round;
const PORT = process.env.PORT || 3000;
await app.listen(PORT);
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();

View File

@@ -0,0 +1,76 @@
import { Controller, Get, Post, Body, Req, Res, UseGuards, Render, Param } from '@nestjs/common';
import { Response } from 'express';
import { AuthenticatedGuard } from '../auth/guards/authenticated.guard';
import { MealPlannerService } from './meal-planner.service';
@Controller('meal-planner')
@UseGuards(AuthenticatedGuard)
export class MealPlannerController {
constructor(private mealPlannerService: MealPlannerService) {}
@Get()
@Render('meal_planner')
async getMealPlanner(@Req() req) {
try {
const data = await this.mealPlannerService.getWeeklyMealPlans(req.user.id);
return {
user: req.user,
dates: data.dates,
meal_plans: data.mealPlans,
today: data.today,
};
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading meal planner');
return {
user: req.user,
dates: [],
meal_plans: {},
today: new Date().toISOString().split('T')[0],
};
}
}
@Post('auto-generate')
async autoGenerate(@Req() req, @Body() body, @Res() res: Response) {
try {
const { date } = body;
if (!date) {
req.flash('error_msg', 'Please select a date');
return res.redirect('/meal-planner');
}
await this.mealPlannerService.autoGenerate(req.user.id, date);
req.flash('success_msg', 'Meal plan generated successfully!');
res.redirect('/meal-planner');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error generating meal plan');
res.redirect('/meal-planner');
}
}
@Post('add')
async addMealPlan(@Req() req, @Body() body, @Res() res: Response) {
try {
await this.mealPlannerService.addMealPlan(req.user.id, body);
req.flash('success_msg', 'Meal plan updated!');
res.redirect('/meal-planner');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error adding meal plan');
res.redirect('/meal-planner');
}
}
@Post('complete/:id')
async toggleComplete(@Req() req, @Param('id') id: number, @Res() res: Response) {
try {
await this.mealPlannerService.toggleComplete(req.user.id, id);
res.json({ success: true });
} catch (err) {
console.error(err);
res.status(500).json({ success: false });
}
}
}

View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { MealPlannerController } from './meal-planner.controller';
import { MealPlannerService } from './meal-planner.service';
import { MealPlan } from '../models/meal-plan.model';
import { PlannedFood } from '../models/planned-food.model';
import { FoodItem } from '../models/food-item.model';
import { UtilsModule } from '../utils/utils.module';
@Module({
imports: [
SequelizeModule.forFeature([MealPlan, PlannedFood, FoodItem]),
UtilsModule,
],
controllers: [MealPlannerController],
providers: [MealPlannerService],
})
export class MealPlannerModule {}

View File

@@ -0,0 +1,155 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { MealPlan } from '../models/meal-plan.model';
import { PlannedFood } from '../models/planned-food.model';
import { FoodItem } from '../models/food-item.model';
import { UtilsService } from '../utils/utils.service';
@Injectable()
export class MealPlannerService {
constructor(
@InjectModel(MealPlan) private mealPlanModel: typeof MealPlan,
@InjectModel(PlannedFood) private plannedFoodModel: typeof PlannedFood,
@InjectModel(FoodItem) private foodItemModel: typeof FoodItem,
private utilsService: UtilsService,
) {}
async getWeeklyMealPlans(userId: number) {
const today = new Date();
const day = today.getDay();
const diff = today.getDate() - day + (day === 0 ? -6 : 1); // adjust when day is sunday
const startDate = new Date(today);
startDate.setDate(diff);
const dates = [];
for (let i = 0; i < 7; i++) {
const d = new Date(startDate);
d.setDate(startDate.getDate() + i);
dates.push(d);
}
const mealPlans = {};
for (const d of dates) {
const dateStr = d.toISOString().split('T')[0];
const plans = await this.mealPlanModel.findAll({
where: { UserId: userId, date: dateStr },
include: [
{
model: PlannedFood,
include: [FoodItem],
},
],
});
mealPlans[dateStr] = plans.map((p) => {
let calories = 0,
protein = 0,
carbs = 0,
fat = 0;
const foods = [];
if (p.plannedFoods) {
p.plannedFoods.forEach((pf) => {
if (pf.foodItem) {
const q = pf.quantity || 1;
calories += pf.foodItem.calories * q;
protein += pf.foodItem.protein_g * q;
carbs += pf.foodItem.carbs_g * q;
fat += pf.foodItem.fat_g * q;
foods.push({
name: pf.foodItem.name,
quantity: q,
});
}
});
}
return {
id: p.id,
meal_type: p.meal_type,
is_completed: p.is_completed,
foods: foods,
totals: {
calories,
protein,
carbs,
fat,
},
};
});
}
return {
dates: dates.map((d) => d.toISOString().split('T')[0]),
mealPlans,
today: new Date().toISOString().split('T')[0],
};
}
async autoGenerate(userId: number, date: string) {
return this.utilsService.generateDailyMealPlan(userId, date);
}
async addMealPlan(userId: number, data: any) {
let { date, meal_type, 'food_id[]': foodIds, 'quantity[]': quantities } = data;
if (!foodIds) {
throw new Error('Please add at least one food item');
}
// Normalize input
if (!Array.isArray(foodIds)) {
foodIds = [foodIds];
quantities = [quantities];
}
// Find or create meal plan
let mealPlan = await this.mealPlanModel.findOne({
where: {
UserId: userId,
date: date,
meal_type: meal_type,
},
});
if (!mealPlan) {
mealPlan = await this.mealPlanModel.create({
UserId: userId,
date: date,
meal_type: meal_type,
} as any);
}
// Add foods
for (let i = 0; i < foodIds.length; i++) {
const foodId = foodIds[i];
const quantity = quantities[i];
if (foodId && quantity) {
await this.plannedFoodModel.create({
MealPlanId: mealPlan.id,
FoodItemId: foodId,
quantity: quantity,
} as any);
}
}
return mealPlan;
}
async toggleComplete(userId: number, planId: number) {
const plan = await this.mealPlanModel.findOne({
where: { id: planId, UserId: userId },
});
if (plan) {
plan.is_completed = !plan.is_completed;
await plan.save();
return plan;
}
return null;
}
}

View File

@@ -0,0 +1,168 @@
import { Controller, Get, Post, Body, Req, Res, UseGuards, Query } from '@nestjs/common';
import { Response } from 'express';
import { InjectModel } from '@nestjs/sequelize';
import { AuthenticatedGuard } from '../auth/guards/authenticated.guard';
import { UtilsService } from '../utils/utils.service';
import { NutritionService } from './nutrition.service';
import { Meal } from '../models/meal.model';
import { MealFood } from '../models/meal-food.model';
import { FoodItem } from '../models/food-item.model';
import { WaterLog } from '../models/water-log.model';
import { WeightLog } from '../models/weight-log.model';
@Controller()
@UseGuards(AuthenticatedGuard)
export class MealsController {
constructor(
private utilsService: UtilsService,
private nutritionService: NutritionService,
@InjectModel(Meal) private mealModel: typeof Meal,
@InjectModel(MealFood) private mealFoodModel: typeof MealFood,
@InjectModel(FoodItem) private foodItemModel: typeof FoodItem,
@InjectModel(WaterLog) private waterLogModel: typeof WaterLog,
@InjectModel(WeightLog) private weightLogModel: typeof WeightLog,
) {}
@Get('add-meal')
addMealPage(@Req() req, @Res() res: Response) {
res.render('add_meal', { today: new Date().toISOString().split('T')[0] });
}
@Post('add-meal')
async addMeal(@Req() req, @Res() res: Response, @Body() body) {
try {
let { date, meal_type, time, 'food_id[]': foodIds, 'quantity[]': quantities } = body;
if (!foodIds) {
req.flash('error_msg', 'Please add at least one food item');
return res.redirect('/add-meal');
}
// Normalize input (handle single item vs array)
if (!Array.isArray(foodIds)) {
foodIds = [foodIds];
quantities = [quantities];
}
const meal = await this.mealModel.create({
UserId: req.user.id,
date: date || new Date(),
meal_type,
time: time || null,
} as any);
for (let i = 0; i < foodIds.length; i++) {
const foodId = foodIds[i];
const quantity = quantities[i];
if (foodId && quantity) {
const food = await this.foodItemModel.findByPk(foodId);
if (food) {
await this.mealFoodModel.create({
MealId: meal.id,
FoodItemId: foodId,
quantity: quantity,
calories_consumed: food.calories * quantity,
protein_consumed: food.protein_g * quantity,
carbs_consumed: food.carbs_g * quantity,
fat_consumed: food.fat_g * quantity,
} as any);
}
}
}
await this.utilsService.updateDailySummary(req.user.id, date);
req.flash('success_msg', 'Meal added successfully!');
res.redirect('/dashboard');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error adding meal');
res.redirect('/add-meal');
}
}
@Get('api/search-food')
async searchFood(@Query('q') query: string) {
if (!query || query.length < 2) {
return [];
}
return this.nutritionService.searchAllSources(query);
}
@Post('api/add-food')
async addFood(@Body() body) {
try {
const food = await this.nutritionService.saveFoodToDb(body);
if (food) {
return {
success: true,
food_id: food.id,
name: food.name,
};
} else {
return { success: false };
}
} catch (err) {
console.error(err);
return { success: false };
}
}
@Post('add-water')
async addWater(@Req() req, @Res() res: Response, @Body() body) {
try {
const amountMl = parseInt(body.amount_ml) || 250;
const date = body.date || new Date().toISOString().split('T')[0];
await this.waterLogModel.create({
UserId: req.user.id,
date: date,
amount_ml: amountMl,
time: new Date().toISOString(), // Use ISO string or appropriate time format
} as any);
await this.utilsService.updateDailySummary(req.user.id, date);
req.flash('success_msg', `Added ${amountMl}ml of water!`);
res.redirect('/dashboard');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error adding water');
res.redirect('/dashboard');
}
}
@Post('add-weight')
async addWeight(@Req() req, @Res() res: Response, @Body() body) {
try {
const weightKg = parseFloat(body.weight_kg);
const date = body.date || new Date().toISOString().split('T')[0];
const weightLog = await this.weightLogModel.findOne({
where: { UserId: req.user.id, date: date },
});
if (weightLog) {
weightLog.weight_kg = weightKg;
weightLog.time = new Date().toISOString();
await weightLog.save();
} else {
await this.weightLogModel.create({
UserId: req.user.id,
date: date,
weight_kg: weightKg,
time: new Date().toISOString(),
} as any);
}
await this.utilsService.updateDailySummary(req.user.id, date);
res.redirect('/dashboard');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error adding weight');
res.redirect('/dashboard');
}
}
}

26
src/meals/meals.module.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { MealsController } from './meals.controller';
import { NutritionService } from './nutrition.service';
import { Meal } from '../models/meal.model';
import { MealFood } from '../models/meal-food.model';
import { FoodItem } from '../models/food-item.model';
import { WaterLog } from '../models/water-log.model';
import { WeightLog } from '../models/weight-log.model';
import { APICache } from '../models/api-cache.model';
@Module({
imports: [
SequelizeModule.forFeature([
Meal,
MealFood,
FoodItem,
WaterLog,
WeightLog,
APICache,
]),
],
controllers: [MealsController],
providers: [NutritionService],
})
export class MealsModule {}

View File

@@ -0,0 +1,231 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { ConfigService } from '@nestjs/config';
import { Op } from 'sequelize';
import axios from 'axios';
import { FoodItem } from '../models/food-item.model';
import { APICache } from '../models/api-cache.model';
@Injectable()
export class NutritionService {
private apiKey: string;
private baseUrl: string;
private headers: any;
private cacheDurationDays: number;
constructor(
private configService: ConfigService,
@InjectModel(FoodItem) private foodItemModel: typeof FoodItem,
@InjectModel(APICache) private apiCacheModel: typeof APICache,
) {
this.apiKey = this.configService.get<string>('API_NINJAS_KEY');
this.baseUrl = 'https://api.api-ninjas.com/v1/nutrition';
this.headers = { 'X-Api-Key': this.apiKey };
this.cacheDurationDays = 30;
}
async searchFood(query: string) {
// Check cache first
const cached = await this._getFromCache(query);
if (cached) {
return cached;
}
// Make API request
try {
if (!this.apiKey) {
console.warn('API_NINJAS_KEY is not set');
return [];
}
const response = await axios.get(this.baseUrl, {
headers: this.headers,
params: { query: query },
timeout: 10000,
});
if (response.status === 200) {
const data = response.data;
// Cache the response
await this._saveToCache(query, 'api_ninjas', data);
// Parse and return standardized format
return this._parseApiResponse(data);
} else {
console.error(`API Error: ${response.status}`);
return [];
}
} catch (error) {
console.error(`API Request failed: ${error.message}`);
return [];
}
}
async _getFromCache(query: string) {
const cacheEntry = await this.apiCacheModel.findOne({
where: { query: query.toLowerCase() },
});
if (cacheEntry) {
// Check if cache is still valid (30 days)
const age = (new Date().getTime() - new Date(cacheEntry.cached_at).getTime()) / (1000 * 60 * 60 * 24);
if (age < this.cacheDurationDays) {
const data = JSON.parse(cacheEntry.response_json);
return this._parseApiResponse(data);
}
}
return null;
}
async _saveToCache(query: string, source: string, data: any) {
try {
let cacheEntry = await this.apiCacheModel.findOne({
where: { query: query.toLowerCase() },
});
if (cacheEntry) {
// Update existing cache
cacheEntry.response_json = JSON.stringify(data);
cacheEntry.cached_at = new Date();
await cacheEntry.save();
} else {
// Create new cache entry
await this.apiCacheModel.create({
query: query.toLowerCase(),
api_source: source,
response_json: JSON.stringify(data),
cached_at: new Date(),
} as any);
}
} catch (error) {
console.error(`Cache save failed: ${error.message}`);
}
}
_parseApiResponse(data: any[]) {
return data.map((item) => ({
name: (item.name || '').replace(/\b\w/g, (l) => l.toUpperCase()), // Title case
calories: item.calories || 0,
protein_g: item.protein_g || 0,
carbs_g: item.carbohydrates_total_g || 0,
fat_g: item.fat_total_g || 0,
fiber_g: item.fiber_g || 0,
sugar_g: item.sugar_g || 0,
sodium_mg: item.sodium_mg || 0,
serving_size_g: item.serving_size_g || 100,
source: 'api_ninjas',
}));
}
async saveFoodToDb(foodData: any) {
try {
// Check if food already exists
const existing = await this.foodItemModel.findOne({
where: {
name: foodData.name,
source: foodData.source || 'api',
},
});
if (existing) {
return existing;
}
// Create new food item
const food = await this.foodItemModel.create({
name: foodData.name,
calories: foodData.calories,
protein_g: foodData.protein_g || 0,
carbs_g: foodData.carbs_g || 0,
fat_g: foodData.fat_g || 0,
fiber_g: foodData.fiber_g || 0,
sugar_g: foodData.sugar_g || 0,
sodium_mg: foodData.sodium_mg || 0,
serving_size_g: foodData.serving_size_g || 100,
serving_description: foodData.serving_description || '1 serving',
source: foodData.source || 'api',
api_data: JSON.stringify(foodData),
} as any);
return food;
} catch (error) {
console.error(`Error saving food to DB: ${error.message}`);
return null;
}
}
async searchAllSources(query: string) {
const results = [];
// 1. Search Filipino foods first
const filipinoFoods = await this.foodItemModel.findAll({
where: {
is_filipino: true,
[Op.or]: [
{ name: { [Op.like]: `%${query}%` } },
{ name_tagalog: { [Op.like]: `%${query}%` } },
],
},
limit: 5,
});
for (const food of filipinoFoods) {
results.push({
id: food.id,
name: food.name,
name_tagalog: food.name_tagalog,
calories: food.calories,
protein_g: food.protein_g,
carbs_g: food.carbs_g,
fat_g: food.fat_g,
serving_description: food.serving_description,
source: 'filipino',
category: food.category,
});
}
// 2. Search other local foods
const otherFoods = await this.foodItemModel.findAll({
where: {
is_filipino: false,
name: { [Op.like]: `%${query}%` },
},
limit: 5,
});
for (const food of otherFoods) {
results.push({
id: food.id,
name: food.name,
calories: food.calories,
protein_g: food.protein_g,
carbs_g: food.carbs_g,
fat_g: food.fat_g,
serving_description: food.serving_description,
source: food.source,
});
}
// 3. If not enough results, search API
if (results.length < 3 && this.apiKey) {
const apiResults = await this.searchFood(query);
for (const foodData of apiResults.slice(0, 5)) {
results.push({
name: foodData.name,
calories: foodData.calories,
protein_g: foodData.protein_g,
carbs_g: foodData.carbs_g || 0,
fat_g: foodData.fat_g || 0,
serving_description: '100g', // Standard API serving
source: 'api_ninjas',
// Pass raw data for saving later
raw_data: foodData,
});
}
}
return results;
}
}

View File

@@ -0,0 +1,16 @@
import { Column, Model, Table, DataType } from 'sequelize-typescript';
@Table
export class APICache extends Model {
@Column({ unique: true, allowNull: false })
query: string;
@Column
api_source: string;
@Column(DataType.TEXT)
response_json: string;
@Column({ defaultValue: DataType.NOW })
cached_at: Date;
}

View File

@@ -0,0 +1,39 @@
import { Column, Model, Table, BelongsTo, ForeignKey, DataType } from 'sequelize-typescript';
import { User } from './user.model';
@Table
export class DailySummary extends Model {
@ForeignKey(() => User)
@Column
UserId: number;
@BelongsTo(() => User)
user: User;
@Column({ type: DataType.DATEONLY, allowNull: false })
date: Date;
@Column({ type: DataType.FLOAT, defaultValue: 0 })
total_calories: number;
@Column({ type: DataType.FLOAT, defaultValue: 0 })
total_protein_g: number;
@Column({ type: DataType.FLOAT, defaultValue: 0 })
total_carbs_g: number;
@Column({ type: DataType.FLOAT, defaultValue: 0 })
total_fat_g: number;
@Column({ defaultValue: 0 })
total_water_ml: number;
@Column(DataType.FLOAT)
calories_remaining: number;
@Column(DataType.FLOAT)
weight_kg: number;
@Column(DataType.TEXT)
notes: string;
}

View File

@@ -0,0 +1,63 @@
import { Column, Model, Table, HasMany, DataType } from 'sequelize-typescript';
import { MealFood } from './meal-food.model';
import { PlannedFood } from './planned-food.model';
@Table
export class FoodItem extends Model {
@Column({ allowNull: false })
name: string;
@Column
name_tagalog: string;
@Column
category: string;
@Column({ type: DataType.FLOAT, allowNull: false })
calories: number;
@Column({ type: DataType.FLOAT, defaultValue: 0 })
protein_g: number;
@Column({ type: DataType.FLOAT, defaultValue: 0 })
carbs_g: number;
@Column({ type: DataType.FLOAT, defaultValue: 0 })
fat_g: number;
@Column({ type: DataType.FLOAT, defaultValue: 0 })
fiber_g: number;
@Column({ type: DataType.FLOAT, defaultValue: 0 })
sugar_g: number;
@Column({ type: DataType.FLOAT, defaultValue: 0 })
sodium_mg: number;
@Column({ type: DataType.FLOAT, defaultValue: 100 })
serving_size_g: number;
@Column
serving_description: string;
@Column({ defaultValue: 'manual' })
source: string;
@Column({ defaultValue: false })
is_filipino: boolean;
@Column({ defaultValue: false })
is_favorite: boolean;
@Column(DataType.TEXT)
api_data: string;
@Column({ defaultValue: DataType.NOW })
last_updated: Date;
@HasMany(() => MealFood)
mealFoods: MealFood[];
@HasMany(() => PlannedFood)
plannedFoods: PlannedFood[];
}

View File

@@ -0,0 +1,38 @@
import { Column, Model, Table, BelongsTo, ForeignKey, DataType } from 'sequelize-typescript';
import { Meal } from './meal.model';
import { FoodItem } from './food-item.model';
@Table
export class MealFood extends Model {
@ForeignKey(() => Meal)
@Column
MealId: number;
@BelongsTo(() => Meal)
meal: Meal;
@ForeignKey(() => FoodItem)
@Column
FoodItemId: number;
@BelongsTo(() => FoodItem)
foodItem: FoodItem;
@Column({ type: DataType.FLOAT, allowNull: false, defaultValue: 1.0 })
quantity: number;
@Column(DataType.FLOAT)
quantity_grams: number;
@Column(DataType.FLOAT)
calories_consumed: number;
@Column(DataType.FLOAT)
protein_consumed: number;
@Column(DataType.FLOAT)
carbs_consumed: number;
@Column(DataType.FLOAT)
fat_consumed: number;
}

View File

@@ -0,0 +1,28 @@
import { Column, Model, Table, BelongsTo, HasMany, ForeignKey, DataType } from 'sequelize-typescript';
import { User } from './user.model';
import { PlannedFood } from './planned-food.model';
@Table
export class MealPlan extends Model {
@ForeignKey(() => User)
@Column
UserId: number;
@BelongsTo(() => User)
user: User;
@Column({ type: DataType.DATEONLY, allowNull: false })
date: Date;
@Column({ allowNull: false })
meal_type: string;
@Column({ defaultValue: false })
is_completed: boolean;
@Column(DataType.TEXT)
notes: string;
@HasMany(() => PlannedFood)
plannedFoods: PlannedFood[];
}

28
src/models/meal.model.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Column, Model, Table, BelongsTo, HasMany, ForeignKey, DataType } from 'sequelize-typescript';
import { User } from './user.model';
import { MealFood } from './meal-food.model';
@Table
export class Meal extends Model {
@ForeignKey(() => User)
@Column
UserId: number;
@BelongsTo(() => User)
user: User;
@Column({ type: DataType.DATEONLY, allowNull: false, defaultValue: DataType.NOW })
date: Date;
@Column({ allowNull: false })
meal_type: string;
@Column(DataType.TIME)
time: string;
@Column(DataType.TEXT)
notes: string;
@HasMany(() => MealFood)
mealFoods: MealFood[];
}

View File

@@ -0,0 +1,23 @@
import { Column, Model, Table, BelongsTo, ForeignKey, DataType } from 'sequelize-typescript';
import { MealPlan } from './meal-plan.model';
import { FoodItem } from './food-item.model';
@Table
export class PlannedFood extends Model {
@ForeignKey(() => MealPlan)
@Column
MealPlanId: number;
@BelongsTo(() => MealPlan)
mealPlan: MealPlan;
@ForeignKey(() => FoodItem)
@Column
FoodItemId: number;
@BelongsTo(() => FoodItem)
foodItem: FoodItem;
@Column({ type: DataType.FLOAT, allowNull: false, defaultValue: 1.0 })
quantity: number;
}

View File

@@ -0,0 +1,33 @@
import { Column, Model, Table, BelongsTo, ForeignKey, DataType } from 'sequelize-typescript';
import { User } from './user.model';
@Table
export class UserGoal extends Model {
@ForeignKey(() => User)
@Column
UserId: number;
@BelongsTo(() => User)
user: User;
@Column({ defaultValue: 'recomp' })
goal_type: string;
@Column(DataType.FLOAT)
target_weight_kg: number;
@Column({ type: DataType.FLOAT, defaultValue: 0.5 })
weekly_goal_kg: number;
@Column({ defaultValue: 150 })
target_protein_g: number;
@Column({ defaultValue: 200 })
target_carbs_g: number;
@Column({ defaultValue: 60 })
target_fat_g: number;
@Column({ defaultValue: 2000 })
target_water_ml: number;
}

67
src/models/user.model.ts Normal file
View File

@@ -0,0 +1,67 @@
import { Column, DataType, Model, Table, HasMany, HasOne, BeforeCreate } from 'sequelize-typescript';
import * as bcrypt from 'bcryptjs';
import { Meal } from './meal.model';
import { WeightLog } from './weight-log.model';
import { WaterLog } from './water-log.model';
import { MealPlan } from './meal-plan.model';
import { UserGoal } from './user-goal.model';
import { DailySummary } from './daily-summary.model';
@Table
export class User extends Model {
@Column({ type: DataType.STRING(80), unique: true, allowNull: false })
username: string;
@Column({ type: DataType.STRING(200), allowNull: false })
password: string;
@Column(DataType.STRING(100))
name: string;
@Column(DataType.INTEGER)
age: number;
@Column(DataType.STRING(10))
gender: string;
@Column(DataType.FLOAT)
height_cm: number;
@Column(DataType.FLOAT)
weight_kg: number;
@Column({ type: DataType.STRING(20), defaultValue: 'moderate' })
activity_level: string;
@Column({ type: DataType.INTEGER, defaultValue: 2000 })
target_daily_calories: number;
@HasMany(() => Meal)
meals: Meal[];
@HasMany(() => WeightLog)
weightLogs: WeightLog[];
@HasMany(() => WaterLog)
waterLogs: WaterLog[];
@HasMany(() => MealPlan)
mealPlans: MealPlan[];
@HasOne(() => UserGoal)
goal: UserGoal;
@HasMany(() => DailySummary)
dailySummaries: DailySummary[];
validPassword(password: string): boolean {
return bcrypt.compareSync(password, this.password);
}
@BeforeCreate
static async hashPassword(user: User) {
if (user.password) {
user.password = await bcrypt.hash(user.password, 10);
}
}
}

View File

@@ -0,0 +1,21 @@
import { Column, Model, Table, BelongsTo, ForeignKey, DataType } from 'sequelize-typescript';
import { User } from './user.model';
@Table
export class WaterLog extends Model {
@ForeignKey(() => User)
@Column
UserId: number;
@BelongsTo(() => User)
user: User;
@Column({ type: DataType.DATEONLY, allowNull: false, defaultValue: DataType.NOW })
date: Date;
@Column({ allowNull: false })
amount_ml: number;
@Column({ defaultValue: DataType.NOW })
time: string;
}

View File

@@ -0,0 +1,27 @@
import { Column, Model, Table, BelongsTo, ForeignKey, DataType } from 'sequelize-typescript';
import { User } from './user.model';
@Table
export class WeightLog extends Model {
@ForeignKey(() => User)
@Column
UserId: number;
@BelongsTo(() => User)
user: User;
@Column({ type: DataType.DATEONLY, allowNull: false, defaultValue: DataType.NOW })
date: Date;
@Column({ type: DataType.FLOAT, allowNull: false })
weight_kg: number;
@Column(DataType.FLOAT)
body_fat_percentage: number;
@Column(DataType.TEXT)
notes: string;
@Column({ defaultValue: DataType.NOW })
time: string;
}

11
src/users/users.module.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { User } from '../models/user.model';
import { UsersService } from './users.service';
@Module({
imports: [SequelizeModule.forFeature([User])],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { User } from '../models/user.model';
@Injectable()
export class UsersService {
constructor(
@InjectModel(User)
private userModel: typeof User,
) {}
async findOne(username: string): Promise<User | null> {
return this.userModel.findOne({ where: { username } });
}
async findById(id: number): Promise<User | null> {
return this.userModel.findByPk(id);
}
async create(createUserDto: any): Promise<User> {
return this.userModel.create(createUserDto);
}
}

32
src/utils/utils.module.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Module, Global } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { UtilsService } from './utils.service';
import { User } from '../models/user.model';
import { FoodItem } from '../models/food-item.model';
import { Meal } from '../models/meal.model';
import { MealFood } from '../models/meal-food.model';
import { WaterLog } from '../models/water-log.model';
import { WeightLog } from '../models/weight-log.model';
import { MealPlan } from '../models/meal-plan.model';
import { PlannedFood } from '../models/planned-food.model';
import { DailySummary } from '../models/daily-summary.model';
@Global()
@Module({
imports: [
SequelizeModule.forFeature([
User,
FoodItem,
Meal,
MealFood,
WaterLog,
WeightLog,
MealPlan,
PlannedFood,
DailySummary,
]),
],
providers: [UtilsService],
exports: [UtilsService],
})
export class UtilsModule {}

414
src/utils/utils.service.ts Normal file
View File

@@ -0,0 +1,414 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import { User } from '../models/user.model';
import { FoodItem } from '../models/food-item.model';
import { Meal } from '../models/meal.model';
import { MealFood } from '../models/meal-food.model';
import { WaterLog } from '../models/water-log.model';
import { WeightLog } from '../models/weight-log.model';
import { MealPlan } from '../models/meal-plan.model';
import { PlannedFood } from '../models/planned-food.model';
import { DailySummary } from '../models/daily-summary.model';
@Injectable()
export class UtilsService {
constructor(
@InjectModel(User) private userModel: typeof User,
@InjectModel(FoodItem) private foodItemModel: typeof FoodItem,
@InjectModel(Meal) private mealModel: typeof Meal,
@InjectModel(WaterLog) private waterLogModel: typeof WaterLog,
@InjectModel(WeightLog) private weightLogModel: typeof WeightLog,
@InjectModel(MealPlan) private mealPlanModel: typeof MealPlan,
@InjectModel(PlannedFood) private plannedFoodModel: typeof PlannedFood,
@InjectModel(DailySummary) private dailySummaryModel: typeof DailySummary,
) {}
calculateBMR(weightKg: number, heightCm: number, age: number, gender: string): number {
let bmr: number;
if (gender.toLowerCase() === 'male') {
bmr = 10 * weightKg + 6.25 * heightCm - 5 * age + 5;
} else {
bmr = 10 * weightKg + 6.25 * heightCm - 5 * age - 161;
}
return Math.round(bmr);
}
calculateTDEE(bmr: number, activityLevel: string): number {
const multipliers: { [key: string]: number } = {
sedentary: 1.2,
light: 1.375,
moderate: 1.55,
active: 1.725,
very_active: 1.9,
};
const multiplier = multipliers[activityLevel] || 1.55;
return Math.round(bmr * multiplier);
}
calculateMacroTargets(weightKg: number, goalType = 'recomp') {
let protein: number, carbs: number, fat: number;
if (goalType === 'muscle_gain') {
protein = weightKg * 2.4;
carbs = weightKg * 3.5;
fat = weightKg * 1.0;
} else if (goalType === 'weight_loss') {
protein = weightKg * 2.2;
carbs = weightKg * 2.0;
fat = weightKg * 0.8;
} else {
protein = weightKg * 2.2;
carbs = weightKg * 2.5;
fat = weightKg * 0.9;
}
return {
protein_g: Math.round(protein),
carbs_g: Math.round(carbs),
fat_g: Math.round(fat),
};
}
async calculateDailyTotals(userId: number, targetDate: string | Date = new Date()) {
const dateStr = targetDate instanceof Date ? targetDate.toISOString().split('T')[0] : targetDate;
const meals = await this.mealModel.findAll({
where: {
UserId: userId,
date: dateStr,
},
include: [
{
model: MealFood,
include: [FoodItem],
},
],
});
const totals = {
calories: 0,
protein: 0,
carbs: 0,
fat: 0,
meals: [],
};
for (const meal of meals) {
const mealTotals = {
calories: 0,
protein: 0,
carbs: 0,
fat: 0,
};
const mealFoods = [];
for (const mf of meal.mealFoods) {
const quantity = mf.quantity || 1.0;
const food = mf.foodItem;
const calories = mf.calories_consumed || food.calories * quantity;
const protein = mf.protein_consumed || food.protein_g * quantity;
const carbs = mf.carbs_consumed || food.carbs_g * quantity;
const fat = mf.fat_consumed || food.fat_g * quantity;
mealTotals.calories += calories;
mealTotals.protein += protein;
mealTotals.carbs += carbs;
mealTotals.fat += fat;
mealFoods.push({
name: food.name,
quantity: quantity,
calories: calories,
});
}
totals.calories += mealTotals.calories;
totals.protein += mealTotals.protein;
totals.carbs += mealTotals.carbs;
totals.fat += mealTotals.fat;
totals.meals.push({
id: meal.id,
type: meal.meal_type,
time: meal.time ? meal.time.substring(0, 5) : null,
totals: mealTotals,
foods: mealFoods,
});
}
return totals;
}
async calculateWaterTotal(userId: number, targetDate: string | Date = new Date()) {
const dateStr = targetDate instanceof Date ? targetDate.toISOString().split('T')[0] : targetDate;
const waterLogs = await this.waterLogModel.findAll({
where: {
UserId: userId,
date: dateStr,
},
});
const total = waterLogs.reduce((sum, log) => sum + log.amount_ml, 0);
return {
total_ml: total,
logs: waterLogs.map((log) => ({
id: log.id,
amount_ml: log.amount_ml,
time: log.time ? log.time.substring(0, 5) : null,
})),
};
}
async getWeightTrend(userId: number, days = 7) {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - (days - 1));
const startDateStr = startDate.toISOString().split('T')[0];
const endDateStr = endDate.toISOString().split('T')[0];
const weightLogs = await this.weightLogModel.findAll({
where: {
UserId: userId,
date: {
[Op.between]: [startDateStr, endDateStr],
},
},
order: [['date', 'ASC']],
});
return weightLogs.map((log) => ({
date: log.date,
weight_kg: log.weight_kg,
}));
}
async getCalorieTrend(userId: number, days = 7) {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - (days - 1));
const trend = [];
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
const dateStr = currentDate.toISOString().split('T')[0];
const totals = await this.calculateDailyTotals(userId, dateStr);
trend.push({
date: dateStr,
calories: Math.round(totals.calories),
protein: Math.round(totals.protein),
carbs: Math.round(totals.carbs),
fat: Math.round(totals.fat),
});
currentDate.setDate(currentDate.getDate() + 1);
}
return trend;
}
async updateDailySummary(userId: number, targetDate: string | Date = new Date()) {
const dateStr = targetDate instanceof Date ? targetDate.toISOString().split('T')[0] : targetDate;
const nutrition = await this.calculateDailyTotals(userId, dateStr);
const water = await this.calculateWaterTotal(userId, dateStr);
const weightLog = await this.weightLogModel.findOne({
where: {
UserId: userId,
date: dateStr,
},
});
const weight = weightLog ? weightLog.weight_kg : null;
const user = await this.userModel.findByPk(userId);
const targetCalories = user ? user.target_daily_calories : 2000;
let summary = await this.dailySummaryModel.findOne({
where: {
UserId: userId,
date: dateStr,
},
});
if (!summary) {
summary = await this.dailySummaryModel.create({
UserId: userId,
date: dateStr,
} as any);
}
summary.total_calories = nutrition.calories;
summary.total_protein_g = nutrition.protein;
summary.total_carbs_g = nutrition.carbs;
summary.total_fat_g = nutrition.fat;
summary.total_water_ml = water.total_ml;
summary.calories_remaining = targetCalories - nutrition.calories;
summary.weight_kg = weight;
try {
await summary.save();
return summary;
} catch (e) {
console.error(`Error updating daily summary: ${e}`);
return null;
}
}
getMacroPercentages(proteinG: number, carbsG: number, fatG: number) {
const proteinCal = proteinG * 4;
const carbsCal = carbsG * 4;
const fatCal = fatG * 9;
const totalCal = proteinCal + carbsCal + fatCal;
if (totalCal === 0) {
return { protein: 0, carbs: 0, fat: 0 };
}
return {
protein: Math.round((proteinCal / totalCal) * 100),
carbs: Math.round((carbsCal / totalCal) * 100),
fat: Math.round((fatCal / totalCal) * 100),
};
}
suggestFoodsForMacros(remainingProtein: number, remainingCarbs: number, remainingFat: number) {
const suggestions = [];
if (remainingProtein > 30) {
suggestions.push({
category: 'High Protein Ulam',
examples: ['Grilled Tilapia', 'Chicken Tinola', 'Grilled Chicken'],
});
}
if (remainingCarbs > 40) {
suggestions.push({
category: 'Carbs',
examples: ['White Rice', 'Pandesal', 'Sweet Potato'],
});
}
if (remainingFat > 20) {
suggestions.push({
category: 'Healthy Fats',
examples: ['Sisig', 'Lechon Kawali', 'Bicol Express'],
});
}
if (remainingProtein > 20 && remainingCarbs > 30) {
suggestions.push({
category: 'Balanced Meals',
examples: ['Tapsilog', 'Chicken Adobo with Rice', 'Sinigang'],
});
}
return suggestions;
}
async generateDailyMealPlan(userId: number, dateStr: string) {
try {
const user = await this.userModel.findByPk(userId);
const targetCalories = user ? user.target_daily_calories : 2000;
const targets = {
breakfast: Math.round(targetCalories * 0.25),
lunch: Math.round(targetCalories * 0.35),
dinner: Math.round(targetCalories * 0.3),
snack: Math.round(targetCalories * 0.1),
};
const foods = await this.foodItemModel.findAll();
const foodByCat = {
almusal: foods.filter((f) => f.category === 'almusal'),
ulam: foods.filter((f) => ['ulam', 'sabaw'].includes(f.category)),
kanin: foods.filter((f) => f.category === 'kanin'),
gulay: foods.filter((f) => f.category === 'gulay'),
meryenda: foods.filter((f) => f.category === 'meryenda'),
};
const allFoods = foods;
const pickRandom = (arr: FoodItem[]) => (arr.length > 0 ? arr[Math.floor(Math.random() * arr.length)] : null);
const generateMeal = async (mealType: string, targetCal: number) => {
let currentCal = 0;
const selectedFoods: { food: FoodItem; qty: number }[] = [];
if (mealType === 'breakfast') {
const main = pickRandom(foodByCat.almusal) || pickRandom(allFoods);
if (main) {
selectedFoods.push({ food: main, qty: 1 });
currentCal += main.calories;
}
} else if (['lunch', 'dinner'].includes(mealType)) {
const rice = pickRandom(foodByCat.kanin);
const ulam = pickRandom(foodByCat.ulam) || pickRandom(allFoods);
const gulay = pickRandom(foodByCat.gulay);
if (rice) {
selectedFoods.push({ food: rice, qty: 1 });
currentCal += rice.calories;
}
if (ulam) {
selectedFoods.push({ food: ulam, qty: 1 });
currentCal += ulam.calories;
}
if (gulay && currentCal < targetCal) {
selectedFoods.push({ food: gulay, qty: 1 });
currentCal += gulay.calories;
}
} else if (mealType === 'snack') {
const snack = pickRandom(foodByCat.meryenda) || pickRandom(allFoods);
if (snack) {
selectedFoods.push({ food: snack, qty: 1 });
currentCal += snack.calories;
}
}
if (currentCal > 0 && selectedFoods.length > 0) {
const mealPlan = await this.mealPlanModel.create({
UserId: userId,
date: dateStr,
meal_type: mealType,
is_completed: false,
} as any);
for (const item of selectedFoods) {
await this.plannedFoodModel.create({
MealPlanId: mealPlan.id,
FoodItemId: item.food.id,
quantity: item.qty,
} as any);
}
}
};
await this.mealPlanModel.destroy({
where: {
UserId: userId,
date: dateStr,
},
});
await generateMeal('breakfast', targets.breakfast);
await generateMeal('lunch', targets.lunch);
await generateMeal('dinner', targets.dinner);
await generateMeal('snack', targets.snack);
return true;
} catch (error) {
console.error('Error generating meal plan:', error);
throw error;
}
}
}