Phase 8
This commit is contained in:
parent
029940b079
commit
e396f5088c
36 changed files with 4199 additions and 22 deletions
166
packages/api/src/modules/meal-plans/meal-plans.service.ts
Normal file
166
packages/api/src/modules/meal-plans/meal-plans.service.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue