MeshiTrack/packages/api/src/modules/meal-plans/meal-plans.service.ts

151 lines
4.9 KiB
TypeScript

import type { MealPlanRepository } from './meal-plans.repository.js';
import { NotFoundError, BadRequestError } from '../../common/errors.js';
import type {
CreateMealPlanInput,
UpdateMealPlanInput,
MealPlanQueryInput,
MealPlanStatus,
NutritionInfo,
MealPlanDaySchema,
} from '@meshitrack/shared';
import { type z } from 'zod/v4';
type MealPlanDay = z.infer<typeof MealPlanDaySchema>;
interface Deps {
mealPlanRepository: MealPlanRepository;
}
export class MealPlanService {
private readonly mealPlanRepository: MealPlanRepository;
public constructor({ mealPlanRepository }: Deps) {
this.mealPlanRepository = mealPlanRepository;
}
public async list(householdId: string, query: MealPlanQueryInput) {
return this.mealPlanRepository.findByHousehold(householdId, query);
}
public async getById(id: string, householdId: string) {
const plan = await this.mealPlanRepository.findById(id, householdId);
if (!plan) {
throw new NotFoundError('Meal plan not found');
}
return plan;
}
public async getByWeek(householdId: string, weekStartDate: string) {
return this.mealPlanRepository.findByWeek(householdId, weekStartDate);
}
public async create(householdId: string, createdBy: string, input: CreateMealPlanInput) {
// Prevent overlapping meal plans for same household/week
const existing = await this.mealPlanRepository.findByWeek(householdId, input.weekStartDate);
if (existing) {
throw new BadRequestError(
`A meal plan already exists for household ${householdId} starting ${input.weekStartDate}`,
);
}
// Force re-calculation of daily totals to ensure correctness
const updatedDays = input.days.map((day) => this.computeDayTotals(day));
return this.mealPlanRepository.create({
...input,
days: updatedDays,
householdId,
createdBy,
});
}
public async update(id: string, householdId: string, input: UpdateMealPlanInput) {
const existing = await this.getById(id, householdId);
const data: Record<string, unknown> = {};
if (input.status !== undefined) data.status = input.status;
if (input.shoppingListId !== undefined) data.shoppingListId = input.shoppingListId;
if (input.days !== undefined) {
// Force re-calculation of daily totals
data.days = input.days.map((day) => this.computeDayTotals(day));
}
const updated = await this.mealPlanRepository.update(id, householdId, data);
if (!updated) {
throw new NotFoundError('Meal plan not found');
}
return updated;
}
public async updateStatus(id: string, householdId: string, status: MealPlanStatus) {
await this.getById(id, householdId);
const updated = await this.mealPlanRepository.updateStatus(id, householdId, status);
if (!updated) {
throw new NotFoundError('Meal plan not found');
}
return updated;
}
public async delete(id: string, householdId: string) {
await this.getById(id, householdId);
const deleted = await this.mealPlanRepository.delete(id, householdId);
if (!deleted) {
throw new NotFoundError('Meal plan not found');
}
return deleted;
}
/** Calculates standard daily totals based on the component meals. */
private computeDayTotals(day: MealPlanDay): MealPlanDay {
const total = {
calories: 0,
protein: 0,
carbs: 0,
fat: 0,
fiber: 0,
sugar: 0,
sodium: 0,
saturatedFat: 0,
cholesterol: 0,
};
for (const meal of day.meals) {
const servings = meal.servings;
// Use customNutrition if provided, otherwise scale perServingNutrition
const source = meal.customNutrition ?? meal.perServingNutrition;
total.calories += source.calories * servings;
total.protein += source.protein * servings;
total.carbs += source.carbs * servings;
total.fat += source.fat * servings;
if (source.fiber != null) total.fiber += source.fiber * servings;
if (source.sugar != null) total.sugar += source.sugar * servings;
if (source.sodium != null) total.sodium += source.sodium * servings;
if (source.saturatedFat != null) {
total.saturatedFat += source.saturatedFat * servings;
}
if (source.cholesterol != null) {
total.cholesterol += source.cholesterol * servings;
}
}
// Round final totals to 2 decimal places
return {
...day,
dailyNutritionTotal: {
calories: Math.round(total.calories * 100) / 100,
protein: Math.round(total.protein * 100) / 100,
carbs: Math.round(total.carbs * 100) / 100,
fat: Math.round(total.fat * 100) / 100,
fiber: Math.round(total.fiber * 100) / 100,
sugar: Math.round(total.sugar * 100) / 100,
sodium: Math.round(total.sodium * 100) / 100,
saturatedFat: Math.round(total.saturatedFat * 100) / 100,
cholesterol: Math.round(total.cholesterol * 100) / 100,
},
};
}
}