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

167 lines
5.1 KiB
TypeScript
Raw Normal View History

2026-05-14 18:38:50 +09:00
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 { 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: NutritionInfo = {
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 = (total.fiber ?? 0) + source.fiber * servings;
if (source.sugar != null) total.sugar = (total.sugar ?? 0) + source.sugar * servings;
if (source.sodium != null) total.sodium = (total.sodium ?? 0) + source.sodium * servings;
if (source.saturatedFat != null) {
total.saturatedFat = (total.saturatedFat ?? 0) + source.saturatedFat * servings;
}
if (source.cholesterol != null) {
total.cholesterol = (total.cholesterol ?? 0) + 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 ?? 0) * 100) / 100,
sugar: Math.round((total.sugar ?? 0) * 100) / 100,
sodium: Math.round((total.sodium ?? 0) * 100) / 100,
saturatedFat: Math.round((total.saturatedFat ?? 0) * 100) / 100,
cholesterol: Math.round((total.cholesterol ?? 0) * 100) / 100,
},
};
}
}