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:
67
src/app.controller.ts
Normal file
67
src/app.controller.ts
Normal 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
40
src/app.module.ts
Normal 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
13
src/auth/auth.module.ts
Normal 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
17
src/auth/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
src/auth/guards/authenticated.guard.ts
Normal file
9
src/auth/guards/authenticated.guard.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
12
src/auth/guards/local-auth.guard.ts
Normal file
12
src/auth/guards/local-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
src/auth/local.strategy.ts
Normal file
19
src/auth/local.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
src/auth/session.serializer.ts
Normal file
19
src/auth/session.serializer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/common/middleware/locals.middleware.ts
Normal file
15
src/common/middleware/locals.middleware.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
120
src/dashboard/dashboard.controller.ts
Normal file
120
src/dashboard/dashboard.controller.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/dashboard/dashboard.module.ts
Normal file
16
src/dashboard/dashboard.module.ts
Normal 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 {}
|
||||
63
src/dashboard/progress.controller.ts
Normal file
63
src/dashboard/progress.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/database/database.module.ts
Normal file
44
src/database/database.module.ts
Normal 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 {}
|
||||
32
src/foods/foods.controller.ts
Normal file
32
src/foods/foods.controller.ts
Normal 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
12
src/foods/foods.module.ts
Normal 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 {}
|
||||
50
src/foods/foods.service.ts
Normal file
50
src/foods/foods.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
46
src/goals/goals.controller.ts
Normal file
46
src/goals/goals.controller.ts
Normal 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
17
src/goals/goals.module.ts
Normal 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
100
src/goals/goals.service.ts
Normal 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
60
src/main.ts
Normal 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();
|
||||
76
src/meal-planner/meal-planner.controller.ts
Normal file
76
src/meal-planner/meal-planner.controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/meal-planner/meal-planner.module.ts
Normal file
18
src/meal-planner/meal-planner.module.ts
Normal 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 {}
|
||||
155
src/meal-planner/meal-planner.service.ts
Normal file
155
src/meal-planner/meal-planner.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
168
src/meals/meals.controller.ts
Normal file
168
src/meals/meals.controller.ts
Normal 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
26
src/meals/meals.module.ts
Normal 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 {}
|
||||
231
src/meals/nutrition.service.ts
Normal file
231
src/meals/nutrition.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
src/models/api-cache.model.ts
Normal file
16
src/models/api-cache.model.ts
Normal 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;
|
||||
}
|
||||
39
src/models/daily-summary.model.ts
Normal file
39
src/models/daily-summary.model.ts
Normal 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;
|
||||
}
|
||||
63
src/models/food-item.model.ts
Normal file
63
src/models/food-item.model.ts
Normal 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[];
|
||||
}
|
||||
38
src/models/meal-food.model.ts
Normal file
38
src/models/meal-food.model.ts
Normal 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;
|
||||
}
|
||||
28
src/models/meal-plan.model.ts
Normal file
28
src/models/meal-plan.model.ts
Normal 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
28
src/models/meal.model.ts
Normal 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[];
|
||||
}
|
||||
23
src/models/planned-food.model.ts
Normal file
23
src/models/planned-food.model.ts
Normal 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;
|
||||
}
|
||||
33
src/models/user-goal.model.ts
Normal file
33
src/models/user-goal.model.ts
Normal 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
67
src/models/user.model.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/models/water-log.model.ts
Normal file
21
src/models/water-log.model.ts
Normal 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;
|
||||
}
|
||||
27
src/models/weight-log.model.ts
Normal file
27
src/models/weight-log.model.ts
Normal 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
11
src/users/users.module.ts
Normal 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 {}
|
||||
23
src/users/users.service.ts
Normal file
23
src/users/users.service.ts
Normal 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
32
src/utils/utils.module.ts
Normal 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
414
src/utils/utils.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user