added imlementation of API,

updated AGENTS.md by Codex
This commit is contained in:
2026-02-12 01:36:37 +01:00
parent 6b718a3cfa
commit 5b93cdd002
2 changed files with 894 additions and 9 deletions

View File

@ -15,12 +15,11 @@ It describes what the project is, what is already implemented, and what still ne
- `backend/` - `backend/`
- `frontend/` - `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. - `README.md` already contains product specification in Slovak and English.
- Backend DB migrations exist in `backend/src/Maintenance.php` up to version `7`. - Backend DB migrations exist in `backend/src/Maintenance.php` up to version `7`.
- Backend API routes are not implemented yet: - Backend API methods are implemented in `backend/src/API.php`.
- `backend/src/API.php` extends `APIlite` but has no endpoints.
- Frontend is still template-level: - Frontend is still template-level:
- `frontend/src/App.vue` has placeholder content. - `frontend/src/App.vue` has placeholder content.
- `frontend/src/router/index.ts` has empty `routes: []`. - `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` - Supports `mysql` and `sqlite`
- Loads override config files from `backend/config/*.php` (except the base file itself) - Loads override config files from `backend/config/*.php` (except the base file itself)
- Migration logic: `backend/src/Maintenance.php` - Migration logic: `backend/src/Maintenance.php`
- API implementation: `backend/src/API.php`
- DB table models: `backend/src/Models/*.php`
## Database Schema Snapshot ## Database Schema Snapshot
@ -55,6 +56,51 @@ Migration creates these tables:
- `diary_entry_id`, `diary_day_id`, `meal_type`, `meal_id` - `diary_entry_id`, `diary_day_id`, `meal_type`, `meal_id`
- unique: `(diary_day_id, meal_type)` - unique: `(diary_day_id, meal_type)`
## API Surface (Implemented)
All actions are invoked through `backend/public/API.php` with `?action=<method_name>`.
- 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 ## Known Pitfalls and Notes
- The historical FK issue in `meal_items` should reference `meals(meal_id)`. - 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. - 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. 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. - 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 ## Local Runbook
@ -82,15 +131,15 @@ Frontend:
## Product Behavior Target (what to build next) ## Product Behavior Target (what to build next)
- CRUD for ingredients with per-100g nutrition values. - Implement auth and session handling (replace plain `user_id` input model).
- CRUD for meals grouped by day part (`breakfast`, `lunch`, `dinner`). - Build frontend screens for ingredients, meals, meal item editor, diary day, diary range.
- Add meal items by ingredient + grams. - Connect frontend to implemented backend actions.
- Compute per-item nutrition and calories from grams. - Add API tests for validation, ownership checks, and totals calculation consistency.
- Compute totals for full meal. - Add pagination/filter strategy where list endpoints grow.
- Diary day view: assign one meal per day part and compute whole-day totals.
## Practical Conventions ## Practical Conventions
- Keep IDs as `BIGINT UNSIGNED` and use `*_id` naming consistently. - Keep IDs as `BIGINT UNSIGNED` and use `*_id` naming consistently.
- Keep MySQL + SQLite compatibility in SQL where possible (project supports both). - 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. - 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.

View File

@ -5,7 +5,843 @@ namespace TPsoft\Nutrio;
require_once __DIR__.'/Init.php'; require_once __DIR__.'/Init.php';
use TPsoft\APIlite\APIlite; 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 { 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;
}
} }