151 lines
4.9 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|
|
}
|