diff --git a/AGENTS.md b/AGENTS.md index 26d90c3..9e71148 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,12 +15,11 @@ It describes what the project is, what is already implemented, and what still ne - `backend/` - `frontend/` -## Current State (as of 2026-02-11) +## Current State (as of 2026-02-12) - `README.md` already contains product specification in Slovak and English. - Backend DB migrations exist in `backend/src/Maintenance.php` up to version `7`. -- Backend API routes are not implemented yet: - - `backend/src/API.php` extends `APIlite` but has no endpoints. +- Backend API methods are implemented in `backend/src/API.php`. - Frontend is still template-level: - `frontend/src/App.vue` has placeholder content. - `frontend/src/router/index.ts` has empty `routes: []`. @@ -34,6 +33,8 @@ It describes what the project is, what is already implemented, and what still ne - Supports `mysql` and `sqlite` - Loads override config files from `backend/config/*.php` (except the base file itself) - Migration logic: `backend/src/Maintenance.php` +- API implementation: `backend/src/API.php` +- DB table models: `backend/src/Models/*.php` ## Database Schema Snapshot @@ -55,6 +56,51 @@ Migration creates these tables: - `diary_entry_id`, `diary_day_id`, `meal_type`, `meal_id` - unique: `(diary_day_id, meal_type)` +## API Surface (Implemented) + +All actions are invoked through `backend/public/API.php` with `?action=`. + +- Utility: + - `health` +- Ingredients: + - `ingredientList(user_id, query = "", include_global = true)` + - `ingredientGet(user_id, ingredient_id)` + - `ingredientCreate(user_id, name, protein_g_100, carbs_g_100, sugar_g_100, fat_g_100, fiber_g_100 = 0, kcal_100 = 0)` + - `ingredientUpdate(user_id, ingredient_id, name, protein_g_100, carbs_g_100, sugar_g_100, fat_g_100, fiber_g_100 = 0, kcal_100 = 0)` + - `ingredientDelete(user_id, ingredient_id)` +- Meals: + - `mealList(user_id, meal_type = "", with_items = false, with_totals = false)` + - `mealGet(user_id, meal_id, with_items = true, with_totals = true)` + - `mealCreate(user_id, name, meal_type)` + - `mealUpdate(user_id, meal_id, name, meal_type)` + - `mealDelete(user_id, meal_id)` +- Meal items: + - `mealItemList(user_id, meal_id, with_calculated = true)` + - `mealItemAdd(user_id, meal_id, ingredient_id, grams, position = 1)` + - `mealItemUpdate(user_id, meal_item_id, ingredient_id, grams, position)` + - `mealItemDelete(user_id, meal_item_id)` + - `mealItemReorder(user_id, meal_id, ordered_item_ids)` +- Calculations: + - `mealTotals(user_id, meal_id)` +- Diary: + - `diaryDayGet(user_id, day_date, with_totals = true)` + - `diaryDaySetMeal(user_id, day_date, meal_type, meal_id)` + - `diaryDayUnsetMeal(user_id, day_date, meal_type)` + - `diaryRange(user_id, date_from, date_to)` + +## Behavior and Validation Rules + +- `meal_type` must be one of: `breakfast`, `lunch`, `dinner`. +- Date params must use format `YYYY-MM-DD`. +- `grams` must be `> 0`. +- Nutrition input values are validated as non-negative. +- If `kcal_100` is `0`, API computes kcal by formula: + - `protein*4 + carbs*4 + fat*9` +- Ownership checks are enforced by `user_id`: + - meals and meal items must belong to the user + - ingredients can be user-owned or global (`user_id = null`) for read/select +- API currently requires an existing `users` record for almost all actions. + ## Known Pitfalls and Notes - The historical FK issue in `meal_items` should reference `meals(meal_id)`. @@ -62,6 +108,9 @@ Migration creates these tables: - If someone ran migrations before FK fix, old MySQL state may still be broken. In that case reset affected table(s) or rebuild DB from clean state. - Some comments in `Maintenance.php` show encoding artifacts, but SQL structure is valid. +- Authentication is not implemented yet; `user_id` is passed as an action parameter. +- For `array` parameters (for example `ordered_item_ids`), APIlite expects JSON in request payload. +- APIlite wraps responses with a nested `data` object. Keep this in mind on frontend parsing. ## Local Runbook @@ -82,15 +131,15 @@ Frontend: ## Product Behavior Target (what to build next) -- CRUD for ingredients with per-100g nutrition values. -- CRUD for meals grouped by day part (`breakfast`, `lunch`, `dinner`). -- Add meal items by ingredient + grams. -- Compute per-item nutrition and calories from grams. -- Compute totals for full meal. -- Diary day view: assign one meal per day part and compute whole-day totals. +- Implement auth and session handling (replace plain `user_id` input model). +- Build frontend screens for ingredients, meals, meal item editor, diary day, diary range. +- Connect frontend to implemented backend actions. +- Add API tests for validation, ownership checks, and totals calculation consistency. +- Add pagination/filter strategy where list endpoints grow. ## Practical Conventions - Keep IDs as `BIGINT UNSIGNED` and use `*_id` naming consistently. - Keep MySQL + SQLite compatibility in SQL where possible (project supports both). - When changing schema, always bump DB version in `Maintenance.php` with forward-only migration steps. +- Keep API action names stable unless frontend is updated at the same time. diff --git a/backend/src/API.php b/backend/src/API.php index c71c5c1..7aced24 100644 --- a/backend/src/API.php +++ b/backend/src/API.php @@ -5,7 +5,843 @@ namespace TPsoft\Nutrio; require_once __DIR__.'/Init.php'; use TPsoft\APIlite\APIlite; +use TPsoft\Nutrio\Models\DiaryDays; +use TPsoft\Nutrio\Models\DiaryEntries; +use TPsoft\Nutrio\Models\Ingredients; +use TPsoft\Nutrio\Models\MealItems; +use TPsoft\Nutrio\Models\Meals; +use TPsoft\Nutrio\Models\Options; +use TPsoft\Nutrio\Models\Users; class API extends APIlite { + private ?Users $usersModel = null; + private ?Ingredients $ingredientsModel = null; + private ?Meals $mealsModel = null; + private ?MealItems $mealItemsModel = null; + private ?DiaryDays $diaryDaysModel = null; + private ?DiaryEntries $diaryEntriesModel = null; + private ?Options $optionsModel = null; + + public function health(): array + { + $dbVersion = null; + $versionRow = $this->options()->option('version'); + if (is_array($versionRow) && isset($versionRow['value'])) { + $dbVersion = (string) $versionRow['value']; + } + return array( + 'service' => 'Nutrio API', + 'timestamp' => date('c'), + 'db_type' => $this->users()->getDBtype(), + 'db_version' => $dbVersion + ); + } + + public function ingredientList(int $user_id, string $query = '', bool $include_global = true): array + { + $this->assertUserExists($user_id); + $search = array( + 'user_id' => $include_global ? array($user_id, null) : $user_id + ); + $query = trim($query); + if (strlen($query) > 0) { + $search['name'] = '%' . $query . '%'; + } + $list = $this->ingredients()->getList($search); + $list = is_array($list) ? $list : array(); + $result = array(); + foreach ($list as $row) { + $result[] = $this->mapIngredient($row); + } + return $result; + } + + public function ingredientGet(int $user_id, int $ingredient_id): array + { + $this->assertUserExists($user_id); + return $this->mapIngredient($this->getIngredientAccessible($user_id, $ingredient_id)); + } + + public function ingredientCreate( + int $user_id, + string $name, + float $protein_g_100, + float $carbs_g_100, + float $sugar_g_100, + float $fat_g_100, + float $fiber_g_100 = 0, + float $kcal_100 = 0 + ): array { + $this->assertUserExists($user_id); + $name = $this->normalizeName($name); + $this->assertNonNegative('protein_g_100', $protein_g_100); + $this->assertNonNegative('carbs_g_100', $carbs_g_100); + $this->assertNonNegative('sugar_g_100', $sugar_g_100); + $this->assertNonNegative('fat_g_100', $fat_g_100); + $this->assertNonNegative('fiber_g_100', $fiber_g_100); + if ($kcal_100 < 0) { + throw new \Exception('kcal_100 cannot be negative'); + } + if ($kcal_100 == 0) { + $kcal_100 = $this->computeKcal100($protein_g_100, $carbs_g_100, $fat_g_100); + } + $ingredientId = $this->ingredients()->ingredientSave(array( + 'user_id' => $user_id, + 'name' => $name, + 'protein_g_100' => $this->round2($protein_g_100), + 'carbs_g_100' => $this->round2($carbs_g_100), + 'sugar_g_100' => $this->round2($sugar_g_100), + 'fat_g_100' => $this->round2($fat_g_100), + 'fiber_g_100' => $this->round2($fiber_g_100), + 'kcal_100' => $this->round2($kcal_100), + 'created_at' => '`NOW`' + )); + if ($ingredientId === false) { + throw new \Exception('Failed to create ingredient'); + } + $created = $this->ingredients()->ingredient((int) $ingredientId); + if (!is_array($created)) { + throw new \Exception('Failed to load created ingredient'); + } + return $this->mapIngredient($created); + } + + public function ingredientUpdate( + int $user_id, + int $ingredient_id, + string $name, + float $protein_g_100, + float $carbs_g_100, + float $sugar_g_100, + float $fat_g_100, + float $fiber_g_100 = 0, + float $kcal_100 = 0 + ): array { + $this->assertUserExists($user_id); + $this->getIngredientOwned($user_id, $ingredient_id); + $name = $this->normalizeName($name); + $this->assertNonNegative('protein_g_100', $protein_g_100); + $this->assertNonNegative('carbs_g_100', $carbs_g_100); + $this->assertNonNegative('sugar_g_100', $sugar_g_100); + $this->assertNonNegative('fat_g_100', $fat_g_100); + $this->assertNonNegative('fiber_g_100', $fiber_g_100); + if ($kcal_100 < 0) { + throw new \Exception('kcal_100 cannot be negative'); + } + if ($kcal_100 == 0) { + $kcal_100 = $this->computeKcal100($protein_g_100, $carbs_g_100, $fat_g_100); + } + $updated = $this->ingredients()->ingredient($ingredient_id, array( + 'name' => $name, + 'protein_g_100' => $this->round2($protein_g_100), + 'carbs_g_100' => $this->round2($carbs_g_100), + 'sugar_g_100' => $this->round2($sugar_g_100), + 'fat_g_100' => $this->round2($fat_g_100), + 'fiber_g_100' => $this->round2($fiber_g_100), + 'kcal_100' => $this->round2($kcal_100) + )); + if ($updated === false) { + throw new \Exception('Failed to update ingredient'); + } + $row = $this->ingredients()->ingredient($ingredient_id); + if (!is_array($row)) { + throw new \Exception('Failed to load updated ingredient'); + } + return $this->mapIngredient($row); + } + + public function ingredientDelete(int $user_id, int $ingredient_id): array + { + $this->assertUserExists($user_id); + $this->getIngredientOwned($user_id, $ingredient_id); + $deleted = $this->ingredients()->ingredient($ingredient_id, null); + if ($deleted === false) { + throw new \Exception('Failed to delete ingredient'); + } + return array( + 'deleted' => true, + 'ingredient_id' => $ingredient_id + ); + } + + public function mealList(int $user_id, string $meal_type = '', bool $with_items = false, bool $with_totals = false): array + { + $this->assertUserExists($user_id); + $search = array('user_id' => $user_id); + $meal_type = trim($meal_type); + if (strlen($meal_type) > 0) { + $this->assertMealType($meal_type); + $search['meal_type'] = $meal_type; + } + $rows = $this->meals()->getList($search); + $rows = is_array($rows) ? $rows : array(); + $result = array(); + foreach ($rows as $row) { + $meal = $this->mapMeal($row); + $calculatedItems = null; + if ($with_items) { + $calculatedItems = $this->buildMealItems((int) $meal['meal_id'], true); + $meal['items'] = $calculatedItems; + } + if ($with_totals) { + if (is_array($calculatedItems)) { + $meal['totals'] = $this->totalsFromCalculatedItems($calculatedItems); + } else { + $meal['totals'] = $this->calculateMealTotals((int) $meal['meal_id']); + } + } + $result[] = $meal; + } + return $result; + } + + public function mealGet(int $user_id, int $meal_id, bool $with_items = true, bool $with_totals = true): array + { + $this->assertUserExists($user_id); + $meal = $this->mapMeal($this->getMealOwned($user_id, $meal_id)); + $calculatedItems = null; + if ($with_items) { + $calculatedItems = $this->buildMealItems($meal_id, true); + $meal['items'] = $calculatedItems; + } + if ($with_totals) { + if (is_array($calculatedItems)) { + $meal['totals'] = $this->totalsFromCalculatedItems($calculatedItems); + } else { + $meal['totals'] = $this->calculateMealTotals($meal_id); + } + } + return $meal; + } + + public function mealCreate(int $user_id, string $name, string $meal_type): array + { + $this->assertUserExists($user_id); + $name = $this->normalizeName($name); + $this->assertMealType($meal_type); + $mealId = $this->meals()->mealSave(array( + 'user_id' => $user_id, + 'name' => $name, + 'meal_type' => $meal_type, + 'created_at' => '`NOW`' + )); + if ($mealId === false) { + throw new \Exception('Failed to create meal'); + } + return $this->mealGet($user_id, (int) $mealId, false, false); + } + + public function mealUpdate(int $user_id, int $meal_id, string $name, string $meal_type): array + { + $this->assertUserExists($user_id); + $this->getMealOwned($user_id, $meal_id); + $name = $this->normalizeName($name); + $this->assertMealType($meal_type); + $updated = $this->meals()->meal($meal_id, array( + 'name' => $name, + 'meal_type' => $meal_type + )); + if ($updated === false) { + throw new \Exception('Failed to update meal'); + } + return $this->mealGet($user_id, $meal_id, false, false); + } + + public function mealDelete(int $user_id, int $meal_id): array + { + $this->assertUserExists($user_id); + $this->getMealOwned($user_id, $meal_id); + $deleted = $this->meals()->meal($meal_id, null); + if ($deleted === false) { + throw new \Exception('Failed to delete meal'); + } + return array( + 'deleted' => true, + 'meal_id' => $meal_id + ); + } + + public function mealItemList(int $user_id, int $meal_id, bool $with_calculated = true): array + { + $this->assertUserExists($user_id); + $this->getMealOwned($user_id, $meal_id); + return array( + 'meal_id' => $meal_id, + 'items' => $this->buildMealItems($meal_id, $with_calculated) + ); + } + + public function mealItemAdd(int $user_id, int $meal_id, int $ingredient_id, float $grams, int $position = 1): array + { + $this->assertUserExists($user_id); + $this->getMealOwned($user_id, $meal_id); + $this->assertPositive('grams', $grams); + if ($position < 1) { + throw new \Exception('position must be >= 1'); + } + $this->getIngredientAccessible($user_id, $ingredient_id); + $mealItemId = $this->mealItems()->mealItemSave(array( + 'meal_id' => $meal_id, + 'ingredient_id' => $ingredient_id, + 'grams' => $this->round2($grams), + 'position' => $position + )); + if ($mealItemId === false) { + throw new \Exception('Failed to add meal item'); + } + $item = $this->getMealItemOwned($user_id, (int) $mealItemId); + return $this->enrichMealItem($item); + } + + public function mealItemUpdate(int $user_id, int $meal_item_id, int $ingredient_id, float $grams, int $position): array + { + $this->assertUserExists($user_id); + $item = $this->getMealItemOwned($user_id, $meal_item_id); + $this->assertPositive('grams', $grams); + if ($position < 1) { + throw new \Exception('position must be >= 1'); + } + $this->getIngredientAccessible($user_id, $ingredient_id); + $updated = $this->mealItems()->mealItem($meal_item_id, array( + 'ingredient_id' => $ingredient_id, + 'grams' => $this->round2($grams), + 'position' => $position + )); + if ($updated === false) { + throw new \Exception('Failed to update meal item'); + } + $item = $this->getMealItemOwned($user_id, (int) $item['meal_item_id']); + return $this->enrichMealItem($item); + } + + public function mealItemDelete(int $user_id, int $meal_item_id): array + { + $this->assertUserExists($user_id); + $item = $this->getMealItemOwned($user_id, $meal_item_id); + $deleted = $this->mealItems()->mealItem($meal_item_id, null); + if ($deleted === false) { + throw new \Exception('Failed to delete meal item'); + } + return array( + 'deleted' => true, + 'meal_item_id' => $meal_item_id, + 'meal_id' => (int) $item['meal_id'] + ); + } + + public function mealItemReorder(int $user_id, int $meal_id, array $ordered_item_ids): array + { + $this->assertUserExists($user_id); + $this->getMealOwned($user_id, $meal_id); + if (count($ordered_item_ids) <= 0) { + throw new \Exception('ordered_item_ids cannot be empty'); + } + $currentItems = $this->buildMealItems($meal_id, false); + $currentIds = array(); + foreach ($currentItems as $item) { + $currentIds[] = (int) $item['meal_item_id']; + } + $orderedIds = array_values(array_map('intval', $ordered_item_ids)); + sort($currentIds); + $checkIds = $orderedIds; + sort($checkIds); + if ($currentIds !== $checkIds) { + throw new \Exception('ordered_item_ids must match all meal item ids exactly'); + } + foreach ($orderedIds as $index => $mealItemId) { + $ok = $this->mealItems()->mealItem($mealItemId, array('position' => $index + 1)); + if ($ok === false) { + throw new \Exception('Failed to reorder meal items'); + } + } + return array( + 'meal_id' => $meal_id, + 'items' => $this->buildMealItems($meal_id, true) + ); + } + + public function mealTotals(int $user_id, int $meal_id): array + { + $this->assertUserExists($user_id); + $this->getMealOwned($user_id, $meal_id); + return array( + 'meal_id' => $meal_id, + 'totals' => $this->calculateMealTotals($meal_id) + ); + } + + public function diaryDayGet(int $user_id, string $day_date, bool $with_totals = true): array + { + $this->assertUserExists($user_id); + $this->assertDate($day_date); + return $this->buildDiaryDay($user_id, $day_date, $with_totals); + } + + public function diaryDaySetMeal(int $user_id, string $day_date, string $meal_type, int $meal_id): array + { + $this->assertUserExists($user_id); + $this->assertDate($day_date); + $this->assertMealType($meal_type); + $meal = $this->getMealOwned($user_id, $meal_id); + if ($meal['meal_type'] !== $meal_type) { + throw new \Exception('meal_type does not match selected meal'); + } + $day = $this->ensureDiaryDay($user_id, $day_date); + $existing = $this->diaryEntries()->search('diaryEntries') + ->where(array( + 'diary_day_id' => (int) $day['diary_day_id'], + 'meal_type' => $meal_type + )) + ->toArrayFirst(); + if (is_array($existing) && isset($existing['diary_entry_id'])) { + $updated = $this->diaryEntries()->diaryEntry((int) $existing['diary_entry_id'], array( + 'meal_id' => $meal_id + )); + if ($updated === false) { + throw new \Exception('Failed to update diary entry'); + } + } else { + $inserted = $this->diaryEntries()->diaryEntrySave(array( + 'diary_day_id' => (int) $day['diary_day_id'], + 'meal_type' => $meal_type, + 'meal_id' => $meal_id, + 'created_at' => '`NOW`' + )); + if ($inserted === false) { + throw new \Exception('Failed to create diary entry'); + } + } + return $this->buildDiaryDay($user_id, $day_date, true); + } + + public function diaryDayUnsetMeal(int $user_id, string $day_date, string $meal_type): array + { + $this->assertUserExists($user_id); + $this->assertDate($day_date); + $this->assertMealType($meal_type); + $day = $this->getDiaryDay($user_id, $day_date); + if ($day === false) { + return $this->buildDiaryDay($user_id, $day_date, true); + } + $existing = $this->diaryEntries()->search('diaryEntries') + ->where(array( + 'diary_day_id' => (int) $day['diary_day_id'], + 'meal_type' => $meal_type + )) + ->toArrayFirst(); + if (is_array($existing) && isset($existing['diary_entry_id'])) { + $deleted = $this->diaryEntries()->diaryEntry((int) $existing['diary_entry_id'], null); + if ($deleted === false) { + throw new \Exception('Failed to delete diary entry'); + } + } + return $this->buildDiaryDay($user_id, $day_date, true); + } + + public function diaryRange(int $user_id, string $date_from, string $date_to): array + { + $this->assertUserExists($user_id); + $this->assertDate($date_from); + $this->assertDate($date_to); + if ($date_from > $date_to) { + throw new \Exception('date_from must be before or equal to date_to'); + } + $rows = $this->diaryDays()->search('diaryDays') + ->where(array('user_id' => $user_id)) + ->where(array('day_date' => '>=' . $date_from)) + ->where(array('day_date' => '<=' . $date_to)) + ->order(array('day_date' => 'ASC')) + ->toArray(); + $rows = is_array($rows) ? $rows : array(); + $days = array(); + $totals = $this->emptyTotals(); + foreach ($rows as $row) { + $day = $this->buildDiaryDay($user_id, (string) $row['day_date'], true); + $days[] = $day; + if (isset($day['totals']) && is_array($day['totals'])) { + $totals = $this->addTotals($totals, $day['totals']); + } + } + return array( + 'user_id' => $user_id, + 'date_from' => $date_from, + 'date_to' => $date_to, + 'days' => $days, + 'totals' => $totals + ); + } + + private function users(): Users + { + if ($this->usersModel === null) { + $this->usersModel = new Users(); + } + return $this->usersModel; + } + + private function ingredients(): Ingredients + { + if ($this->ingredientsModel === null) { + $this->ingredientsModel = new Ingredients(); + } + return $this->ingredientsModel; + } + + private function meals(): Meals + { + if ($this->mealsModel === null) { + $this->mealsModel = new Meals(); + } + return $this->mealsModel; + } + + private function mealItems(): MealItems + { + if ($this->mealItemsModel === null) { + $this->mealItemsModel = new MealItems(); + } + return $this->mealItemsModel; + } + + private function diaryDays(): DiaryDays + { + if ($this->diaryDaysModel === null) { + $this->diaryDaysModel = new DiaryDays(); + } + return $this->diaryDaysModel; + } + + private function diaryEntries(): DiaryEntries + { + if ($this->diaryEntriesModel === null) { + $this->diaryEntriesModel = new DiaryEntries(); + } + return $this->diaryEntriesModel; + } + + private function options(): Options + { + if ($this->optionsModel === null) { + $this->optionsModel = new Options(); + } + return $this->optionsModel; + } + + private function assertUserExists(int $user_id): void + { + if ($user_id <= 0) { + throw new \Exception('user_id must be > 0'); + } + if (!$this->users()->exist($user_id)) { + throw new \Exception('User not found'); + } + } + + private function assertMealType(string $meal_type): void + { + $allowed = array('breakfast', 'lunch', 'dinner'); + if (!in_array($meal_type, $allowed, true)) { + throw new \Exception('Invalid meal_type'); + } + } + + private function assertDate(string $day_date): void + { + $dt = \DateTime::createFromFormat('Y-m-d', $day_date); + if (!$dt || $dt->format('Y-m-d') !== $day_date) { + throw new \Exception('Invalid date format, expected YYYY-MM-DD'); + } + } + + private function assertNonNegative(string $name, float $value): void + { + if ($value < 0) { + throw new \Exception($name . ' cannot be negative'); + } + } + + private function assertPositive(string $name, float $value): void + { + if ($value <= 0) { + throw new \Exception($name . ' must be > 0'); + } + } + + private function normalizeName(string $name): string + { + $name = trim($name); + if (strlen($name) <= 0) { + throw new \Exception('name is required'); + } + if (strlen($name) > 255) { + throw new \Exception('name is too long'); + } + return $name; + } + + private function round2(float $value): float + { + return round($value, 2); + } + + private function computeKcal100(float $protein_g_100, float $carbs_g_100, float $fat_g_100): float + { + return $this->round2($protein_g_100 * 4 + $carbs_g_100 * 4 + $fat_g_100 * 9); + } + + private function getMealOwned(int $user_id, int $meal_id): array + { + $meal = $this->meals()->meal($meal_id); + if (!is_array($meal)) { + throw new \Exception('Meal not found'); + } + if ((int) $meal['user_id'] !== $user_id) { + throw new \Exception('Meal does not belong to user'); + } + return $meal; + } + + private function getIngredientAccessible(int $user_id, int $ingredient_id): array + { + $ingredient = $this->ingredients()->ingredient($ingredient_id); + if (!is_array($ingredient)) { + throw new \Exception('Ingredient not found'); + } + if (!is_null($ingredient['user_id']) && (int) $ingredient['user_id'] !== $user_id) { + throw new \Exception('Ingredient is not accessible for user'); + } + return $ingredient; + } + + private function getIngredientOwned(int $user_id, int $ingredient_id): array + { + $ingredient = $this->ingredients()->ingredient($ingredient_id); + if (!is_array($ingredient)) { + throw new \Exception('Ingredient not found'); + } + if (is_null($ingredient['user_id']) || (int) $ingredient['user_id'] !== $user_id) { + throw new \Exception('Ingredient does not belong to user'); + } + return $ingredient; + } + + private function getMealItemOwned(int $user_id, int $meal_item_id): array + { + $item = $this->mealItems()->mealItem($meal_item_id); + if (!is_array($item)) { + throw new \Exception('Meal item not found'); + } + $this->getMealOwned($user_id, (int) $item['meal_id']); + return $this->mapMealItem($item); + } + + private function buildMealItems(int $meal_id, bool $with_calculated = true): array + { + $rows = $this->mealItems()->search('mealItems') + ->where(array('meal_id' => $meal_id)) + ->order(array('position' => 'ASC', 'meal_item_id' => 'ASC')) + ->toArray(); + $rows = is_array($rows) ? $rows : array(); + $result = array(); + foreach ($rows as $row) { + $item = $this->mapMealItem($row); + if ($with_calculated) { + $item = $this->enrichMealItem($item); + } + $result[] = $item; + } + return $result; + } + + private function enrichMealItem(array $item): array + { + $ingredient = $this->ingredients()->ingredient((int) $item['ingredient_id']); + if (!is_array($ingredient)) { + throw new \Exception('Ingredient for meal item was not found'); + } + $ingredient = $this->mapIngredient($ingredient); + $factor = ((float) $item['grams']) / 100; + $kcal100 = (float) $ingredient['kcal_100']; + if ($kcal100 <= 0) { + $kcal100 = $this->computeKcal100( + (float) $ingredient['protein_g_100'], + (float) $ingredient['carbs_g_100'], + (float) $ingredient['fat_g_100'] + ); + } + $item['ingredient'] = $ingredient; + $item['nutrition'] = array( + 'protein_g' => $this->round2(((float) $ingredient['protein_g_100']) * $factor), + 'carbs_g' => $this->round2(((float) $ingredient['carbs_g_100']) * $factor), + 'sugar_g' => $this->round2(((float) $ingredient['sugar_g_100']) * $factor), + 'fat_g' => $this->round2(((float) $ingredient['fat_g_100']) * $factor), + 'fiber_g' => $this->round2(((float) $ingredient['fiber_g_100']) * $factor), + 'kcal' => $this->round2($kcal100 * $factor) + ); + return $item; + } + + private function calculateMealTotals(int $meal_id): array + { + $items = $this->buildMealItems($meal_id, true); + return $this->totalsFromCalculatedItems($items); + } + + private function totalsFromCalculatedItems(array $items): array + { + $totals = $this->emptyTotals(); + foreach ($items as $item) { + if (!isset($item['nutrition']) || !is_array($item['nutrition'])) { + continue; + } + $totals = $this->addTotals($totals, $item['nutrition']); + } + return $totals; + } + + private function emptyTotals(): array + { + return array( + 'protein_g' => 0.0, + 'carbs_g' => 0.0, + 'sugar_g' => 0.0, + 'fat_g' => 0.0, + 'fiber_g' => 0.0, + 'kcal' => 0.0 + ); + } + + private function addTotals(array $base, array $add): array + { + foreach ($base as $key => $value) { + $base[$key] = $this->round2((float) $value + (float) ($add[$key] ?? 0)); + } + return $base; + } + + private function getDiaryDay(int $user_id, string $day_date): array|false + { + $rows = $this->diaryDays()->getList(array( + 'user_id' => $user_id, + 'day_date' => $day_date + )); + if (!is_array($rows) || count($rows) <= 0) { + return false; + } + return $rows[0]; + } + + private function ensureDiaryDay(int $user_id, string $day_date): array + { + $day = $this->getDiaryDay($user_id, $day_date); + if (is_array($day)) { + return $this->mapDiaryDay($day); + } + $dayId = $this->diaryDays()->diaryDaySave(array( + 'user_id' => $user_id, + 'day_date' => $day_date, + 'created_at' => '`NOW`' + )); + if ($dayId === false) { + throw new \Exception('Failed to create diary day'); + } + $created = $this->diaryDays()->diaryDay((int) $dayId); + if (!is_array($created)) { + throw new \Exception('Failed to load created diary day'); + } + return $this->mapDiaryDay($created); + } + + private function buildDiaryDay(int $user_id, string $day_date, bool $with_totals): array + { + $day = $this->getDiaryDay($user_id, $day_date); + if (!is_array($day)) { + $ret = array( + 'user_id' => $user_id, + 'day_date' => $day_date, + 'diary_day_id' => null, + 'entries' => array() + ); + if ($with_totals) { + $ret['totals'] = $this->emptyTotals(); + } + return $ret; + } + $day = $this->mapDiaryDay($day); + $entriesRows = $this->diaryEntries()->search('diaryEntries') + ->where(array('diary_day_id' => (int) $day['diary_day_id'])) + ->order(array('diary_entry_id' => 'ASC')) + ->toArray(); + $entriesRows = is_array($entriesRows) ? $entriesRows : array(); + $entries = array(); + $totals = $this->emptyTotals(); + foreach ($entriesRows as $row) { + $entry = $this->mapDiaryEntry($row); + $meal = $this->mapMeal($this->getMealOwned($user_id, (int) $entry['meal_id'])); + $entry['meal'] = $meal; + if ($with_totals) { + $mealTotals = $this->calculateMealTotals((int) $entry['meal_id']); + $entry['meal_totals'] = $mealTotals; + $totals = $this->addTotals($totals, $mealTotals); + } + $entries[] = $entry; + } + $ret = array( + 'user_id' => $user_id, + 'day_date' => $day_date, + 'diary_day_id' => (int) $day['diary_day_id'], + 'entries' => $entries + ); + if ($with_totals) { + $ret['totals'] = $totals; + } + return $ret; + } + + private function mapIngredient(array $row): array + { + $row['ingredient_id'] = (int) $row['ingredient_id']; + $row['user_id'] = is_null($row['user_id']) ? null : (int) $row['user_id']; + $row['protein_g_100'] = $this->round2((float) $row['protein_g_100']); + $row['carbs_g_100'] = $this->round2((float) $row['carbs_g_100']); + $row['sugar_g_100'] = $this->round2((float) $row['sugar_g_100']); + $row['fat_g_100'] = $this->round2((float) $row['fat_g_100']); + $row['fiber_g_100'] = $this->round2((float) $row['fiber_g_100']); + $row['kcal_100'] = $this->round2((float) $row['kcal_100']); + return $row; + } + + private function mapMeal(array $row): array + { + $row['meal_id'] = (int) $row['meal_id']; + $row['user_id'] = (int) $row['user_id']; + return $row; + } + + private function mapMealItem(array $row): array + { + $row['meal_item_id'] = (int) $row['meal_item_id']; + $row['meal_id'] = (int) $row['meal_id']; + $row['ingredient_id'] = (int) $row['ingredient_id']; + $row['grams'] = $this->round2((float) $row['grams']); + $row['position'] = (int) $row['position']; + return $row; + } + + private function mapDiaryDay(array $row): array + { + $row['diary_day_id'] = (int) $row['diary_day_id']; + $row['user_id'] = (int) $row['user_id']; + return $row; + } + + private function mapDiaryEntry(array $row): array + { + $row['diary_entry_id'] = (int) $row['diary_entry_id']; + $row['diary_day_id'] = (int) $row['diary_day_id']; + $row['meal_id'] = (int) $row['meal_id']; + return $row; + } + }